| name | fake-summoner-server |
| description | FakeSummoner test harness for **server-side** tests — socket handlers, channel integration, pipeline, DB, CLI process. Use when writing or debugging server tests that need real socket.io + in-memory DB + fake CLI process. For client tests see `fake-summoner-client` skill.
|
FakeSummoner — Server Tests
核心概念
FakeSummoner = 一個 測試用的 window/連線,同時擁有:
- 真的 socket.io client(連到測試用 FakeServer)
- 一個 FakeClaude(模擬 CLI 過程)
- FilesystemService / GitService 的記憶體 fake 實作
每個 FakeSummoner 代表「一個 UI window」。多 window 測試建立多個 summoner 共用同一 server。
Import 來源
| 東西 | 從哪 import |
|---|
createFakeSummoner / createFakeServer / createTestContainer | ../test/index(server 專用版,會 wire DI container) |
segments as s | @code-quest/summoner/test |
Server tests 一律從 ../test/index.ts import — 那是包過 DI container 的版本。@code-quest/summoner/test 的同名 export 是 summoner base 版,給 summoner package 本身測試用。
最小 Setup
import { segments as s } from '@code-quest/summoner/test';
import { createFakeSummoner } from '../test/index.ts';
const claude = createFakeSummoner().claude();
const channelId = await claude.initialize();
initialize() 跑完整 CLI 啟動流程:spawn → ready → init segment。
含 DI container(存取 services)
import { createFakeServer, createFakeSummoner, createTestContainer } from '../test/index.ts';
async function setup(sessionId = 'cli-sess') {
const container = createTestContainer();
const server = createFakeServer(container);
const summoner = createFakeSummoner(server);
const claude = summoner.claude();
const channelId = await claude.initialize(s.init(sessionId));
return { container, claude, channelId };
}
FakeSummoner API
const summoner = createFakeSummoner(server);
summoner.claude()
summoner.socket
summoner.filesystem()
summoner.git()
summoner.openspec()
summoner.pluginCli()
summoner.connected
summoner.disconnect()
await summoner.send<T>(event, payload)
summoner.receivedEvents()
summoner.receivedEvents('session:init')
summoner.sentEvents()
summoner.sentEvents('session:launch')
summoner.on(event, fn)
summoner.holdEmit(event)
FakeClaude API
const claude = summoner.claude();
await claude.initialize(initSeg?)
await claude.initialize({ launch: { cwd, ... } }, seg)
claude.prepareInit(segment)
await claude.emitSegment(s.assistant('Hi'))
await claude.emitSegment(s.result())
await claude.emitSegment(s.controlRequest('r1', 'can_use_tool', 'Bash', { command: 'ls' }))
await claude.send<LaunchOk>('session:launch', { channelId: 'ch-1' })
await claude.send('chat:send', { channelId, message: 'go' })
await claude.send('chat:respond', { channelId, requestId, response })
claude.receivedEvents()
claude.receivedEvents<E>('message:assistant')
claude.received()
claude.received<T>('control_response')
claude.setControlRequestHandler((req) => { ... })
claude.pushServerEvent('event', payload)
claude.pushSessionState(channelId, state, opts)
claude.pushSessionClosed(channelId, error?)
claude.provider
claude.handle
claude.lastInitRequestId
claude.connected
claude.disconnect()
FakeSocket 架構
createFakeSocket() 建立一個 dual-emitter:
- client → server: 同步 delivery(callback pattern 需立即回應)
- server → client: async via
queueMicrotask(模擬真實 socket.io 網路延遲)
interface FakeSocket {
id: string;
connected: boolean;
serverSocket: FakeServerSocket;
connect(): FakeSocket;
disconnect(): FakeSocket;
on / once / off / emit / listeners
}
serverSocket.lastHandlerPromise — 等待最後一個 server handler 的 async 完成(send() 內部使用)。
常見 pipeline:message 往返
const { claude, channelId } = await setup();
await claude.send('chat:send', { channelId, message: 'hi' });
await claude.emitSegment(s.assistant('Hello!'));
await claude.emitSegment(s.result());
const events = claude.receivedEvents('message:assistant');
expect(events[0].content[0].text).toBe('Hello!');
Tool use + permission 流程
await claude.emitSegment(
s.assistant({ toolUse: { id: 'toolu_1', name: 'Bash', input: { command: 'ls' } } })
);
await claude.emitSegment(s.controlRequest('req-1', 'can_use_tool', 'Bash', { command: 'ls' }));
const permEvents = claude.receivedEvents('control:permission');
expect(permEvents[0].requestId).toBe('req-1');
await claude.send('chat:respond', {
channelId,
requestId: 'req-1',
response: { behavior: 'allow', updatedInput: {} },
});
Control request handler(自訂回應)
claude.setControlRequestHandler((req) => {
if (req.subtype === 'side_question') {
return { response: 'The answer is 42', synthetic: false };
}
if (req.subtype === 'reload_plugins') {
return { agents: [...], plugins: [...] };
}
return null;
});
適用情境:
side_question — /btw 邊緣提問
reload_plugins — plugin 熱重載
can_use_tool — MCP 工具授權
open_diff — 檔案 diff 預覽
holdEmit(攔截 ACK delivery)
const held = summoner.holdEmit('session:join');
summoner.send('session:join', { channelId });
held.release();
只攔截第一次 matching emit;後續同名 emit 正常通過。
多 window 在同一 channel(join pattern)
const server = createFakeServer();
const window1 = createFakeSummoner(server);
const window2 = createFakeSummoner(server);
const channelId = await window1.claude().initialize();
await window2.send('session:join', { channelId });
const initEvents = window2.claude().receivedEvents('session:init');
expect(initEvents[0].model).toBe('claude-opus-4-6');
Channel resume(模擬 server 重啟)
const summoner1 = createFakeSummoner(server);
const channelId = await summoner1.claude().initialize(
s.init('sess-x', { model: 'claude-opus-4-6' }),
);
summoner1.claude().handle.abort();
await new Promise<void>((r) => queueMicrotask(r));
const summoner2 = createFakeSummoner(server);
await summoner2.send('session:join', { channelId });
expect(summoner2.claude().receivedEvents('session:init')[0].model).toBe('claude-opus-4-6');
外部 launch(prepareInit)
預備 init segment 但不自動跑,用於測 UI 按「New tab」觸發 session:launch:
const claude = createFakeSummoner().claude();
claude.prepareInit(s.init('prep-sess'));
await claude.send<LaunchOk>('session:launch', { channelId: 'ch-1' });
時序陷阱
忘記 await:claude.emitSegment() 是 async,必須 await 才會 flush microtask。
觀察 event 前要讓 delivery 完成:server → client 是 async,驗證前可加 await new Promise(r => queueMicrotask(r))。
open_diff 之類的 notification 有額外 delay:可能需 setTimeout(r, 50) 等通知流程完成。
Cleanup
每個測試新 instance 就夠了,不需 afterEach:
it('test 1', async () => {
const { claude } = await setup();
});
it('test 2', async () => {
const { claude } = await setup();
});
測斷線 / channel exit:
claude.disconnect();
claude.handle.abort();
await new Promise<void>((r) => queueMicrotask(r));
expect(claude.receivedEvents('session:closed').length).toBeGreaterThan(0);
Channel / 單元測試需要 ProcessRunner
測試 Channel 這類建構時需要 ProcessRunner 但本身不驗證 runner 行為的 class:用真實 ProcessRunner + FakeProcessProvider 組合,維持 runner 的真實介面與生命週期。
import { ClaudeAdapter, ProcessRunner } from '@code-quest/summoner';
import { FakeProcessProvider } from '@code-quest/summoner/test';
function makeRunner() {
return new ProcessRunner({
adapter: new ClaudeAdapter(),
processProvider: new FakeProcessProvider(),
});
}
const channel = new Channel(makeRunner(), 'sess-1', 'claude', '/cwd');
好處:API 變動時測試會跟著更新,不會被型別 cast 靜默略過。
多層驗證(server 版)
Handler 測試預設多層驗證(FakeSummoner 給真 DB + 真 socket + 真 process):
| 層 | API | 抓的 bug |
|---|
| ① RPC response | await claude.send('event', payload) return value | RPC shape 錯 |
| ② Broadcast | claude.receivedEvents('event:name') / 另一 FakeClaude 監聽 | broadcast 漏、channel filter 錯 |
| ③ DB / Store | container.get(TYPES.XxxStore).getByY(...) | store 邏輯、composite fan-out、transaction |
| ④ CLI stdin | claude.received('cmd_type') | controller → CLI writes |
不要只驗 RPC response。副作用真的發生比 response shape 重要。
const res = await claude.send('projects:add', { path: '/tmp/x' });
expect(res).not.toHaveProperty('error');
expect(claude.receivedEvents('projects:added').length).toBeGreaterThan(0);
expect(await projectStore.getByPath('/tmp/x')).not.toBeNull();
對應 client 端在 frontend-testing/references/fake-patterns.md 的 Pattern 3.5。
慣例
| 情境 | 採用 |
|---|
| fake socket | createFakeSummoner() 提供的 dual-emitter FakeSocket |
| fake ProcessRunner | new ProcessRunner({ adapter, processProvider: new FakeProcessProvider() }) |
| protocol event 建構 | segments.*() builder(s.assistant(), s.result(), etc.) |
| 啟動 FakeClaude | createFakeSummoner().claude().initialize() |
| 測啟動流程 | initialize() 或 prepareInit()(外部 launch) |
| 副作用驗證 | 多層(RPC + emit + store + stdin)都驗,不要只驗 RPC |
| 攔截 ACK | summoner.holdEmit(event) → held.release() |
相關 skill
- Client 端 test harness →
fake-summoner-client
- Test double 層級(server)→
vitest-testing
- Fixture-driven TDD(parser/protocol 測試)→
fixture-driven-tdd
- Real CLI JSON 收集 →
collect-cli-fixtures