# 用模拟宿主测试

> createMockHost——一个在内存中运行的 NeuroSquad，执行与应用相同的检查，记录你的卡片做过的事，并能在测试或浏览器预览中驱动它。

Source: https://docs.neurosquad.ai/zh/card-sdk/testing

`@neurosquad/card-sdk/testing` 提供一个内存宿主，它使用真实的协议，并按应用的顺序执行应用的检查——参数 schema、权限、可见性，
以及（如果你愿意）速率限制。存储、设置、智能体、端口、工具、网络和文件都是模拟的。你的卡片代码无需改动就能在它上面运行。

## 单元测试

```ts
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`。 |
| `agentUsage` | `agents.usage` 按智能体 id 返回的数据（覆盖在全零的运行上），或 `(agentId, since, until)` 的函数。之后可用 `setAgentUsage` 修改。 |
| `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` 会显示一张带示例数据、可以正常工作的卡片。
如果你想自己搭建，模式如下：

```ts
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`；
- 没有真实的智能体、终端或箭头——它们就是你传入的数据。
