Skip to Content
Card SDKTesting with the mock host

Testing with the mock host

@neurosquad/card-sdk/testing has an in-memory host that speaks the real protocol and applies the app’s checks in the app’s order — params schemas, permissions, visibility and, if you want, rate limits. Storage, settings, agents, ports, tools, network and files are simulated. Your card code runs unchanged against it.

A unit test

import { createMockHost } from '@neurosquad/card-sdk/testing' import type { CardManifest } from '@neurosquad/card-sdk' import { describe, expect, it } from 'vitest' const manifest: CardManifest = { manifestVersion: 1, name: 'test-radar', displayName: 'Test radar', version: '1.0.0', protocol: 1, permissions: ['agents.read'], ports: { outputs: [{ id: 'summary', label: 'Summary', type: 'ns:markdown' }] }, tools: [{ name: 'summary', description: 'Returns the summary.', inputSchema: { type: 'object' } }] } describe('test radar', () => { it('sends its summary to a connected note and answers the tool', async () => { const host = createMockHost({ manifest, agents: [{ id: 'a1', name: 'Claude Code', harness: 'claude-code', kind: 'ai', status: 'idle', connected: true }], peers: [ { cardId: 'note-1', kind: 'note', name: 'Notes', direction: 'downstream', inputs: [{ id: 'append', label: 'Append', type: 'ns:markdown', mode: 'stream', retain: false, default: true }], outputs: [] } ] }) const card = await host.connect() // The code under test would normally live in your card's module. card.tools.handle('summary', () => '# All green') await card.ports.emit('summary', '# All green') expect(host.deliveries).toEqual([{ to: 'note-1', output: 'summary', input: 'append', data: '# All green' }]) await expect(host.callTool('summary', {})).resolves.toEqual({ content: [{ type: 'text', text: '# All green' }] }) }) it('is refused what it did not declare', async () => { const card = await createMockHost({ manifest }).connect() await expect(card.agents.prompt('a1', 'hi')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }) }) })

The mock needs MessageChannel — Node 18+ and every browser have it.

Options

createMockHost(options):

OptionMeaning
manifestYour manifest, validated like the app does (an invalid one throws). Default: a minimal one.
grantGranted permissions: default every required one; 'all' includes optional ones; or a list of ids.
contextOverrides of the context sent at connect (visibility, i18n, workspace, instance…).
agentsAgentInfo[] in the workspace.
screens, repliesWhat readScreen and lastReply return, per agent id.
peersConnected cards (PeerInfo, plus retained values and a respond function for requests).
filesThe workspace folder: { 'path/to/file': 'text' or Uint8Array }.
fetchHandles net.fetch after the host’s URL and permission checks: (req) => ({ status?, headers?, body?, stream?, error? }). Default: NETWORK_ERROR.
confirmAnswers ui.confirm, openLink and dangerous-mode prompts. Default: yes.
requestPermissionsAnswers permissions.request. Default: grants everything asked.
hostProtocolPretend to be an older or newer app (to test PROTOCOL_MISMATCH).
enforceRateLimitsApply per-method throttles. Default false, so tests stay deterministic.
usageThe UsageSummary returned by usage.summary.
settingsInitial setting values, as if the user had saved them.
secretsValues of secret settings the user has entered: filled into {{secret:key}} headers; a declared secret missing here fails with INVALID_PARAMS, as in the app.
verboseLog every request and event.

Driving the card

MethodDoes
connect(options?)A real Card wired to this host.
setVisibility(state), setExpanded(bool), resize({ w, h }), suspend(graceMs?)Lifecycle events.
setTheme(theme), setLanguage('ru')Theme and language switches.
setSettings(values, secrets?)The user saved the settings form (secrets: which ones are set).
setSecret(key, value)The user entered (or, with null, cleared) a secret setting.
setGrants(ids), setPeers(peers)Change grants or arrows (sends the matching events).
sendPortMessage(input, data, from?)A value arrives on an input.
requestPort(input, data)Ask a request input; resolves with the card’s answer.
callTool(tool, args, { agent?, timeoutMs? })Call a tool like an agent; resolves with the result. cancelTool(callId) cancels.
setAgentStatus(id, status), agentOutput(id, text)Agent events (delivered if the card subscribed); a status change also produces agents.turn by the app’s rule.
touchFile(path, data?), readFile(path)Change a file (notifying watchers), read what the card wrote.
emit(event, data)Send any event.
ping()A heartbeat; resolves when the card answers.
handle(method, fn)Replace a method’s behaviour (runs after the checks).

What it records

calls (every request), events, storage.instance / storage.package (Maps), deliveries, retained, prompts, terminal, logs, toasts, clipboard, spawned, aborted, pongs, subscriptions, chrome (title, status, badge, overview, attention, menu, enabledTools), files, and the current hostContext and grants.

Previews in a browser

Both templates start the mock host when the page is opened outside the app, so npx serve . or npm run dev shows a working card with sample data. The pattern, if you set it up yourself:

import { connect, type Card, type CardManifest } from '@neurosquad/card-sdk' async function start(): Promise<Card> { if (window.parent !== window) return connect() // inside NeuroSquad const { createMockHost } = await import('@neurosquad/card-sdk/testing') const manifest = (await (await fetch('./neurosquad-card.json')).json()) as CardManifest const host = createMockHost({ manifest, grant: 'all' }) Object.assign(window, { mockHost: host }) // drive it from the DevTools console return host.connect() } const app = await start() render(app.instance.displayName)

The mock is faithful about rules, not about the rest of the app: it does not run agents, draw arrows or render your overview tile. Before you publish, run the card for real with neurosquad-card dev.

What the mock does not simulate

The mock mirrors the app’s rules — permissions, visibility (NOT_VISIBLE for dialogs and permission requests), schemas on port values and tool arguments, upstream-only port messages, turn events derived from status changes, secrets filled into headers — but it is not a browser sandbox and not the app’s UI:

  • your page is not sandboxed, so forms can navigate, copy handlers work, nested frames and file workers load — check those in the app with neurosquad-card dev;
  • the app’s dialogs, header and overview tile are not drawn — read host.chrome instead;
  • no real agents, terminals or arrows exist — they are the data you pass in.