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.
npx vitest runA package's test setup is a vitest.config.ts and a test/ folder:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { include: ['test/**/*.test.ts'] } })Create a test host
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),
})| Option | Meaning |
|---|---|
settings | The connector's settings, as an administrator would save them. Defaults and checks apply as in Console |
auth | method (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' |
fixtures | A URL of the folder that holds the cassettes |
cassette | Cassette name. Default: the name of the top-level describe block in kebab case |
messages | The en.json messages, so ctx.t returns text instead of the key |
record, live, scrub | Recording options, below |
files | Files 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
| Member | What 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.requests | The requests the vendor would have seen: method, host, path, query, headers and body, with credentials masked |
host.sql, host.mail, host.net | The fakes below |
host.clock | set(iso) and advance(ms). The clock starts at 2026-01-01T00:00:00Z and drives ctx.now(), the mail limits and signing dates |
host.logs | What the package logged |
The host applies Console's rules, so a test fails for the same reason a real call would:
- A
writeordestructiveaction does not run unless the test passes{ approved: true }; otherwise it throwsAPPROVAL_REQUIRED. This only makes the test state what it expects. In Console, approval is decided per agent. - A request to a host outside
permissions.egressfails withEGRESS_DENIED. - A
readaction that tries to send a write fails withPOLICY_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).
{
"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:
CPC_RECORD=1 npx vitest run test/read-actions.test.tsIn 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.
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.
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 blockstest/helpers.ts builds hosts so each test file starts from one line:
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:
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
mapErrorhandles, checkingcode,hintandretryAfterSec. - Access: that each write or destructive action refuses to run without
approved, and that read actions never send a write. - Settings: that
whenconditions,deploymentsand migrations behave, and that a setting used as a host is covered by egress. - Sources:
runSyncwith and without acursor, withexistingids to check pruning, and withstopAfterCheckpointsto check that a run can continue. - Manifest: build
host.manifest()and assert what a reviewer cares about: each action'saccess, 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:
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')insrc/exists inen.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.
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.

