用模拟宿主测试
@neurosquad/card-sdk/testing 提供一个内存宿主,它使用真实的协议,并按应用的顺序执行应用的检查——参数 schema、权限、可见性,
以及(如果你愿意)速率限制。存储、设置、智能体、端口、工具、网络和文件都是模拟的。你的卡片代码无需改动就能在它上面运行。
单元测试
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' })
})
})模拟宿主需要 MessageChannel——Node 18+ 和所有浏览器都有。
选项
createMockHost(options):
| 选项 | 含义 |
|---|---|
manifest | 你的清单,校验方式与应用相同(无效时抛出异常)。默认是一个最小清单。 |
grant | 授予的权限:默认为所有必需权限;'all' 也包括可选权限;或一个 id 列表。 |
context | 覆盖连接时发送的上下文(visibility、i18n、workspace、instance……)。 |
agents | 工作区中的 AgentInfo[]。 |
screens、replies | 按智能体 id 指定 readScreen 和 lastReply 的返回值。 |
peers | 相连的卡片(PeerInfo,外加 retained 保留值和用于请求的 respond 函数)。 |
files | 工作区文件夹:{ 'path/to/file': 'text' or Uint8Array }。 |
fetch | 在宿主完成 URL 和权限检查后处理 net.fetch:(req) => ({ status?, headers?, body?, stream?, error? })。默认返回 NETWORK_ERROR。 |
confirm | 应答 ui.confirm、openLink 和发给危险模式智能体的提示词。默认同意。 |
requestPermissions | 应答 permissions.request。默认授予所有申请的权限。 |
hostProtocol | 假装是更旧或更新的应用(用于测试 PROTOCOL_MISMATCH)。 |
enforceRateLimits | 应用逐方法的限流。默认 false,让测试保持确定性。 |
usage | usage.summary 返回的 UsageSummary。 |
settings | 初始设置值,就像用户已经保存过一样。 |
secrets | 用户已输入的密钥设置的值:会填入 {{secret:key}} 请求头;已声明但这里缺少的密钥会以 INVALID_PARAMS 失败,与应用一致。 |
verbose | 打印每个请求和事件。 |
驱动卡片
| 方法 | 作用 |
|---|---|
connect(options?) | 一个连接到此宿主的真实 Card。 |
setVisibility(state)、setExpanded(bool)、resize({ w, h })、suspend(graceMs?) | 生命周期事件。 |
setTheme(theme)、setLanguage('ru') | 切换主题和语言。 |
setSettings(values, secrets?) | 模拟用户保存了设置表单(secrets:哪些已设置)。 |
setSecret(key, value) | 模拟用户输入了(传 null 则是清除了)某个密钥设置。 |
setGrants(ids)、setPeers(peers) | 修改授权或箭头(会发送相应事件)。 |
sendPortMessage(input, data, from?) | 某个输入收到一个值。 |
requestPort(input, data) | 向请求型输入提问;返回卡片的回答。 |
callTool(tool, args, { agent?, timeoutMs? }) | 像智能体一样调用工具;返回结果。cancelTool(callId) 可取消。 |
setAgentStatus(id, status)、agentOutput(id, text) | 智能体事件(卡片已订阅时才会投递);状态变化也会按应用的规则产生 agents.turn。 |
touchFile(path, data?)、readFile(path) | 修改文件(通知监视器)、读取卡片写入的内容。 |
emit(event, data) | 发送任意事件。 |
ping() | 一次心跳;卡片应答后返回。 |
handle(method, fn) | 替换某个方法的行为(在检查之后运行)。 |
它会记录什么
calls(每个请求)、events、storage.instance / storage.package(Map)、deliveries、retained、prompts、terminal、logs、
toasts、clipboard、spawned、aborted、pongs、subscriptions、chrome(title、status、badge、overview、attention、menu、
enabledTools)、files,以及当前的 hostContext 和 grants。
在浏览器中预览
两个模板在页面脱离应用打开时都会启动模拟宿主,所以 npx serve . 或 npm run dev 会显示一张带示例数据、可以正常工作的卡片。
如果你想自己搭建,模式如下:
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)模拟宿主忠实还原的是规则,而不是应用的其余部分:它不会运行智能体、不会画箭头,也不会渲染你的概览图块。
发布之前,请用 neurosquad-card dev 真正运行一次卡片。
模拟宿主不模拟的内容
模拟宿主还原了应用的规则——权限、可见性(对话框和权限申请的 NOT_VISIBLE)、端口值和工具参数的 schema、
只接受来自上游的端口消息、由状态变化推导出的轮次事件、填入请求头的密钥——但它既不是浏览器沙箱,也不是应用的界面:
- 你的页面没有被沙箱隔离,所以表单可以跳转、
copy处理函数有效、嵌套框架和文件 Worker 都能加载——请用neurosquad-card dev在应用中检查这些; - 应用的对话框、标题栏和概览图块不会被绘制——请改为读取
host.chrome; - 没有真实的智能体、终端或箭头——它们就是你传入的数据。