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):
| Option | Meaning |
|---|---|
manifest | Your manifest, validated like the app does (an invalid one throws). Default: a minimal one. |
grant | Granted permissions: default every required one; 'all' includes optional ones; or a list of ids. |
context | Overrides of the context sent at connect (visibility, i18n, workspace, instance…). |
agents | AgentInfo[] in the workspace. |
screens, replies | What readScreen and lastReply return, per agent id. |
peers | Connected cards (PeerInfo, plus retained values and a respond function for requests). |
files | The workspace folder: { 'path/to/file': 'text' or Uint8Array }. |
fetch | Handles net.fetch after the host’s URL and permission checks: (req) => ({ status?, headers?, body?, stream?, error? }). Default: NETWORK_ERROR. |
confirm | Answers ui.confirm, openLink and dangerous-mode prompts. Default: yes. |
requestPermissions | Answers permissions.request. Default: grants everything asked. |
hostProtocol | Pretend to be an older or newer app (to test PROTOCOL_MISMATCH). |
enforceRateLimits | Apply per-method throttles. Default false, so tests stay deterministic. |
usage | The UsageSummary returned by usage.summary. |
settings | Initial setting values, as if the user had saved them. |
secrets | Values 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. |
verbose | Log every request and event. |
Driving the card
| Method | Does |
|---|---|
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,
copyhandlers work, nested frames and file workers load — check those in the app withneurosquad-card dev; - the app’s dialogs, header and overview tile are not drawn — read
host.chromeinstead; - no real agents, terminals or arrows exist — they are the data you pass in.