Skip to content

Test a connector package ​

Run a connector's actions, sources and sign-in in tests with the SDK's test host, using recorded HTTP exchanges and in-memory fakes for SQL and mail.

Enterprise module

Connectors are the enterprise connectors module. A workspace without the module gets 402 from the connectors API.

The test host is createTestHost from @cognipeer/connector-sdk/testing. It runs your package the way Console does: the same egress rules, the same credential masking, the same read-only rules and the same limits for SQL and mail. Tests need no network and no Console. They run with Vitest.

sh
npx vitest run

A package's test setup is a vitest.config.ts and a test/ folder:

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({ test: { include: ['test/**/*.test.ts'] } })

Create a test host ​

ts
import { createTestHost } from '@cognipeer/connector-sdk/testing'
import connector from '../src'

const host = createTestHost(connector, {
  deployment: 'saas',
  settings: { host: 'github.com', readonly: false },
  auth: { method: 'pat', credentials: { token: 'test-token-000' } },
  fixtures: new URL('./fixtures/', import.meta.url),
})
OptionMeaning
settingsThe connector's settings, as an administrator would save them. Defaults and checks apply as in Console
authmethod (an auth method id), credentials (the form fields of that method), optional accessToken for an OAuth method and account ({ id, label, captured? }). A none method needs no credentials
deployment'saas' (default) or 'onprem'. http to a host that is not loopback is accepted only on 'onprem'
locale'en' or 'tr'
fixturesA URL of the folder that holds the cassettes
cassetteCassette name. Default: the name of the top-level describe block in kebab case
messagesThe en.json messages, so ctx.t returns text instead of the key
record, live, scrubRecording options, below
filesFiles that ctx.files.read may open, keyed by file id

Use obviously fake values: example.com hosts, 203.0.113.0/24 addresses, tokens like test-token-000. Never use a key that a vendor publishes for its demo servers.

What the host can do ​

MemberWhat it does
host.runAction(name, input, { approved? })Runs an action and returns its result. Input goes through the action's zod schema
host.runSync(source, { mode, filters, cursor?, existing?, stopAfterCheckpoints?, readFiles? })Runs a source and returns { changes, cursor, pruned, stopped }. existing lists ids the host already holds, so you can see what a scopeComplete would prune. stopAfterCheckpoints simulates the run time cap
host.runAuth()Runs authenticate or the identity hook with the test credential
host.options(name, query?), host.browse(provider, query), host.callRpc(name, input)Run the form choice lists, pickers and package screen calls
host.runMcpBeforeCall(tool, args)Runs the MCP beforeCall hook
host.resolve()The values Console would store for the settings: base URL, headers, MCP URL, OAuth endpoints
host.tokenRequest(params?)The token request Console would send for an OAuth method, so you can assert the client authentication, scopes, static headers and the filled token URL
host.manifest()The connector.json that cpc build would write
host.http.requestsThe requests the vendor would have seen: method, host, path, query, headers and body, with credentials masked
host.sql, host.mail, host.netThe fakes below
host.clockset(iso) and advance(ms). The clock starts at 2026-01-01T00:00:00Z and drives ctx.now(), the mail limits and signing dates
host.logsWhat the package logged

The host applies Console's rules, so a test fails for the same reason a real call would:

  • A write or destructive action does not run unless the test passes { approved: true }; otherwise it throws APPROVAL_REQUIRED. This only makes the test state what it expects. In Console, approval is decided per agent.
  • A request to a host outside permissions.egress fails with EGRESS_DENIED.
  • A read action that tries to send a write fails with POLICY_DENIED.
  • Credentials never appear in host.http.requests; they show as [masked].

HTTP cassettes ​

HTTP comes from cassettes: one JSON file per top-level describe block, named in kebab case (describe('list_repos') reads list-repos.json).

json
{
  "recordedAt": "2026-10-25T09:00:00Z",
  "exchanges": [
    {
      "request": {
        "method": "GET",
        "url": "https://api.github.com/user/repos?per_page=30&sort=updated",
        "headers": { "authorization": "[masked]" }
      },
      "response": {
        "status": 200,
        "headers": { "link": "<https://api.github.com/user/repositories?page=2>; rel=\"next\"" },
        "json": [{ "full_name": "example/handbook", "private": true }]
      }
    }
  ]
}

A request matches on method, path, sorted query and JSON body. A request that matches nothing fails the test. response takes json, body or bodyBase64.

Write the cassettes by hand from the vendor's documentation, or record them:

sh
CPC_RECORD=1 npx vitest run test/read-actions.test.ts

In record mode the host calls the real service with the credential you give it and saves the exchanges, masking every credential and every response field named in scrub. It refuses to save while a known secret is still in the file. Keep recorded data free of real account details before you commit it. live: true calls the service without reading or writing cassettes; cpc run uses it.

A response larger than the request's maxBytes fails with UPSTREAM, as in Console; it is not cut.

For a test that needs a large cassette, generate one in a temporary folder and point fixtures and cassette at it, as the GitHub package's generatedCassette helper does.

Fakes for SQL, mail and signing ​

ctx.sql and ctx.mail have no cassette.

SQL ​

Script the answers. The first script that matches answers; times limits how often. A request nothing matches fails the test.

ts
host.sql.script({
  match: { op: 'query', sql: /from people/ },
  result: { columns: [{ name: 'id', type: 'int' }], rows: [[1]] },
})
const out = await host.runAction('find_person', { id: 1 })
expect(host.sql.requests[0]).toMatchObject({ op: 'query' })

match is a string (compared with white space collapsed), a RegExp, an object (op, sql, params, schema, table, search) or a function. result has the shape ctx.sql returns for that operation; a query answer may leave out rowCount and elapsedMs. Use error instead of result to make the call fail with an Error or { code, message, hint?, retryAfterSec? }. Arguments and answers go through the same checks as in Console, so a bad parameter or a malformed answer fails the test too.

Mail ​

The test host is an in-memory mail server. host.mail.sent lists the messages it accepted, host.mail.last() returns the newest, host.mail.previews lists previews, host.mail.requests the requests and host.mail.verifies counts verifications.

ts
const r = await host.runAction('send_email', { to: ['ada@example.com'], subject: 'Hello', text: 'Hi Ada' }, { approved: true })
expect(host.mail.last()).toMatchObject({ envelope: { to: ['ada@example.com'] }, subject: 'Hello' })

host.mail.script({ rejectRecipients: ['bad@example.com'] })
host.mail.script({ op: 'verify', error: { code: 'AUTH_FAILED', message: 'Wrong password.' } })

The server applies the host's rules: strict addresses, no line breaks in a subject or a name, the body caps, the allowed domains and recipient cap from the settings (POLICY_DENIED outside them), the sender and reply-to from the settings only, no bcc in the headers, 10 sends a minute and 60 an hour, and CONFLICT for an identical message within a minute, all measured on host.clock. A script can refuse recipients (rejectRecipients), set the server's answer line (reply) or fail the call (error); match picks requests and times limits uses. host.mail.clear() forgets what was sent. Nothing is ever delivered.

Object storage signing ​

The test host does not sign, so a cassette never contains a signature. It applies what Console applies before signing: it removes the signing headers, checks http.methods, the profile and http.scope, and that a request without a signature only reads. A refusal is a POLICY_DENIED error that ends in the rule's name in brackets, for example [header-vendor]. host.http.requests[n].headers shows authorization as [masked], x-amz-date from the test clock and x-amz-content-sha256 as the real hash of the body. The profile tables are exported from @cognipeer/connector-sdk/testing as checkProfile, checkMethod, checkScope and checkAnonymous.

How the real packages are laid out ​

The official packages keep tests small and specific. Follow the same shape:

test/
  helpers.ts            host factories with fake settings and credentials
  read-actions.test.ts  one describe block per action or topic
  write-actions.test.ts
  errors.test.ts        mapError: one case per status the service uses
  manifest.test.ts      the built manifest
  guides.test.ts        i18n and guides
  fixtures/             cassettes, named after the describe blocks

test/helpers.ts builds hosts so each test file starts from one line:

ts
import { readFileSync } from 'node:fs'
import { createTestHost } from '@cognipeer/connector-sdk/testing'
import connector from '../src'

export const TOKEN = 'test-token-000'
const messages = JSON.parse(readFileSync(new URL('../i18n/en.json', import.meta.url), 'utf8'))

export function nativeHost(settings: Record<string, unknown> = {}) {
  return createTestHost(connector, {
    deployment: 'saas',
    settings: { host: 'github.com', readonly: false, ...settings },
    auth: { method: 'pat', credentials: { token: TOKEN } },
    fixtures: new URL('./fixtures/', import.meta.url),
    messages,
  })
}

An action test asserts the result and the request the vendor would have seen:

ts
import { beforeEach, describe, expect, it } from 'vitest'
import { nativeHost } from './helpers'

describe('list_repos', () => {
  it('lists the repositories and returns nextPage from the Link header', async () => {
    const host = nativeHost()
    const out = await host.runAction<any>('list_repos', { sort: 'updated' })
    expect(out.nextPage).toBe(2)
    expect(host.http.requests[0]).toMatchObject({ method: 'GET', host: 'api.github.com', path: '/user/repos', query: { sort: 'updated' } })
  })
})

What to cover:

  • Every action: the happy path, the request it sends (path, query, body), input a model might get wrong (numbers written as strings), and a pagination edge.
  • Errors: one test per status your mapError handles, checking code, hint and retryAfterSec.
  • Access: that each write or destructive action refuses to run without approved, and that read actions never send a write.
  • Settings: that when conditions, deployments and migrations behave, and that a setting used as a host is covered by egress.
  • Sources: runSync with and without a cursor, with existing ids to check pruning, and with stopAfterCheckpoints to check that a run can continue.
  • Manifest: build host.manifest() and assert what a reviewer cares about: each action's access, the egress hosts, the auth methods.

For a database or mail package, the shared checks (limits, read-only behaviour) are in the SDK; test your own actions, your identity and your settings.

Guide and message tests ​

Guides and messages are part of the product, and cpc build already fails on a missing key or file. The official packages add a test that keeps the two languages in step:

ts
import { readdirSync, readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { nativeHost } from './helpers'

const host = nativeHost()
const dir = new URL('../', import.meta.url)
const messages = (l: string): Record<string, string> => JSON.parse(readFileSync(new URL(`i18n/${l}.json`, dir), 'utf8'))
const headings = (md: string) => md.split('\n').filter((l) => /^#{1,3} /.test(l)).map((l) => /^#+/.exec(l)![0])

it('has the same keys in both languages and no empty text', () => {
  expect(Object.keys(messages('tr')).sort()).toEqual(Object.keys(messages('en')).sort())
  for (const l of ['en', 'tr']) for (const [k, v] of Object.entries(messages(l))) expect(v.trim().length, `${l} ${k}`).toBeGreaterThan(0)
})

it('has a guide file per language for every guide id', () => {
  const guides = Object.keys((host.manifest() as { guides: Record<string, unknown> }).guides).sort()
  const files = readdirSync(new URL('guides/', dir)).sort()
  expect(files).toEqual(guides.flatMap((id) => [`${id}.en.md`, `${id}.tr.md`]).sort())
})

Checks worth adding as the guides grow:

  • every t('key') in src/ exists in en.json, and no key is unused;
  • the English and Turkish version of a guide have the same heading structure;
  • the labels a guide names in bold are the labels in en.json, so a renamed field cannot leave a guide pointing at nothing;
  • a guide that cites a vendor's documentation names the page and the date it was read.

Run an action against the real service ​

cpc run runs one action with a real credential, outside Console and outside tests. It is the quickest check that a request shape is right before you write a cassette.

sh
cpc run . list_things --auth pat --cred token=env:EXAMPLE_TOKEN --input '{"limit":5}'
cpc run . list_things --auth public --input '{}'
cpc run . --source documents --filters '{}' --auth public

--cred name=value passes a form field; name=env:VAR reads it from an environment variable, which keeps tokens out of your shell history. Use --settings '<json>' for settings, --deployment saas|onprem, and --approve to run a write action. Use identity or resolve in place of an action name to run the identity hook or print the resolved settings. --source runs a sync locally and prints the changes it yields, without file bytes.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub