| name | fake-summoner-client |
| description | FakeSummoner test harness for **client-side** tests — React component/context/hook tests that need socket + pipeline. Covers renderWithWorkspace, renderWithChannel, multi-tab scenarios, state injection vs full pipeline. For server tests see `fake-summoner-server` skill.
|
FakeSummoner — Client Tests
核心概念
Client 端 FakeSummoner 繼承 server 基礎版 + 加上 TypedSocket 型別。搭配兩個 render helper:
renderWithWorkspace — 完整 workspace,適合 多 tab / workspace-level 測試
renderWithChannel — 單一 channel 包好,適合 component / context / hook 測試
React 互動必須在 act() 裡 flush。
Import 來源
| 東西 | 從哪 import |
|---|
renderWithChannel / renderWithWorkspace | @/test/render-with-channel / @/test/render-with-workspace |
createFakeSummoner / FakeSummoner | @/test/fake-summoner(含 TypedSocket 強型別) |
createFakeServer | @code-quest/server/test |
segments as s / controlRequest* | @code-quest/summoner/test |
sendUserMessage / emitAssistantTurn | @/test/helpers |
renderWithChannel — 最常用
自動包 SocketProvider → SessionProvider → ... → TabProvider → ChannelProvider,並跑 claude.initialize()。
import { renderWithChannel } from '@/test/render-with-channel';
import { segments as s } from '@code-quest/summoner/test';
const { claude, channelId, user } = await renderWithChannel(<ChatPanel />);
await user.type(screen.getByPlaceholderText(/Esc to focus/i), 'hello{Enter}');
await act(async () => {
await claude.emitSegment(s.assistant('Hi back'));
await claude.emitSegment(s.result());
});
expect(screen.getByText('Hi back')).toBeInTheDocument();
選項
await renderWithChannel(<MyComponent />, {
channelId: 'ch-test',
skipInit: false,
extraSegments: [
s.controlResponse('init', { models: [{ value: 'opus-4-6' }] }),
],
initialState: {
pendingControls: [{ requestId: 'r1', subtype: 'can_use_tool', toolName: 'Bash' }],
},
launchOnMount: false,
cwd: '/test/cwd',
});
skipInit: true 用於外部控制啟動流程(搭配 cwd prop)。
renderWithWorkspace — 多 session 測試
整個 WorkspaceLayout 入口(含 project list / tab bar)。
import { renderWithWorkspace } from '@/test/render-with-workspace';
const { claude, summoner, user, addProject } = await renderWithWorkspace();
const project = await addProject({ path: '/test', dirName: 'app' });
await project.launchSession();
await user.click(screen.getByLabelText('New tab'));
const closeBtns = screen.getAllByLabelText(/^Close /);
expect(closeBtns).toHaveLength(2);
互動 + 事件發送的 act() 模式
使用者操作(user.click/user.type)— testing-library 的 userEvent 已自動 act。
模擬 server push(claude.emitSegment)— 必須手動 act:
await user.click(screen.getByRole('button', { name: 'Submit' }));
await act(async () => {
await claude.emitSegment(s.assistant('response'));
});
便利 helper(專案內):
sendUserMessage(user, 'text') — 打字 + Enter
emitAssistantTurn(claude, 'msg') — 自動包 assistant + result + act
Permission / Control request UI 測試
const { claude } = await renderWithChannel(<ChatPanel />);
await userEvent.type(screen.getByPlaceholderText(/Esc to focus/i), 'go{Enter}');
await act(async () => {
await claude.emitSegment(
s.assistant({ toolUse: { id: 'toolu_1', name: 'Bash', input: { command: 'ls' } } }),
);
await claude.emitSegment(s.controlRequestBash('r1', { command: 'ls' }));
});
expect(screen.getByText('Yes')).toBeInTheDocument();
await user.click(screen.getByText('Yes'));
expect(screen.queryByTestId('pending-banner')).not.toBeInTheDocument();
Segment helpers:s.controlRequestBash()、s.controlRequestOpenInEditor()、s.controlRequestOpenDiff()。
State injection — 什麼時候用
優先 full pipeline(claude.emitSegment)— 最接近真實流程。
initialState 適合以下情境:
- 測純渲染,不關心事件流程(例如「給定 pending control,banner 應顯示」)
- 測初始 conditional render(例如「loading=true 時顯示 spinner」)
- 快速設 edge case state 而不用串一大堆 segment
await renderWithChannel(<ChatPanel />, {
initialState: {
pendingControls: [{ requestId: 'r1', subtype: 'can_use_tool', toolName: 'Bash' }],
},
});
expect(screen.getByText(/Bash/)).toBeInTheDocument();
holdEmit — 測試 loading/connecting 中間狀態
const { summoner, claude } = await renderWithChannel(<ChatPanel />);
const held = summoner.holdEmit('session:join');
expect(screen.getByText('Connecting...')).toBeInTheDocument();
held.release();
await waitFor(() => expect(screen.queryByText('Connecting...')).not.toBeInTheDocument());
跨 Context 測試(WorktreeContext / SessionContext)
需要 prime 服務(例如 git worktree list)時,先用一個 summoner 設好 state,再給另一個 summoner 連上:
import { createFakeServer } from '@code-quest/server/test';
import { FakeSummoner } from '@/test/fake-summoner';
const server = createFakeServer();
const primingSummoner = new FakeSummoner(server);
primingSummoner.git()!.setProjectRoot('/repo');
primingSummoner.git()!.addWorktree({ name: 'w1', path: '...', branch: 'b1' });
function Wrapper({ children }) {
const ref = useRef<FakeSummoner | null>(null);
if (!ref.current) ref.current = new FakeSummoner(server);
return <SocketProvider socket={ref.current.socket}>{children}</SocketProvider>;
}
const { result } = renderHook(() => useWorktree(), { wrapper: Wrapper });
await act(() => result.current.list('/repo'));
expect(result.current.listing['/repo']).toContainEqual({ name: 'w1', ... });
useRef + lazy init 確保 renderHook rerender 時不會重建 summoner(socket 斷線問題)。
多 tab / multi-channel 細節
每個 tab 自己的 channelId(launchSession 時自動產生)。ChannelProvider 依 useChannelId() 路由訊息。
測試多個 channel 的行為:
const { addProject, user } = await renderWithWorkspace();
const projA = await addProject({ path: '/a' });
const projB = await addProject({ path: '/b' });
await projA.launchSession();
await projB.launchSession();
await user.type(screen.getByPlaceholderText(...), 'B message');
await user.click(screen.getByRole('tab', { name: /a/i }));
expect(screen.queryByDisplayValue('B message')).not.toBeInTheDocument();
模擬 server 主動推送
act(() => {
claude.pushServerEvent('projects:added', {
id: '...', path: '/x', name: 'x', pinned: false, color: null,
lastOpenedAt: '...', createdAt: '...',
});
});
await waitFor(() => expect(state.projects).toHaveLength(1));
claude.pushSessionState(channelId, 'processing');
claude.pushSessionClosed(channelId, 'error message');
適用情境:projects:added/updated/removed、notification:show、session:states、其他 server-broadcast 類事件。
如果是測「自己 client 觸發的 server 廣播」,優先用真 pipeline(actions.addProject(cwd) → server handler → 自動 broadcast);pushServerEvent 留給「另一個 tab / system event」場景。
查詢 sent events(client → server)
expect(summoner.sentEvents('session:launch')).toHaveLength(1);
expect(summoner.sentEvents('app:init')).toHaveLength(1);
sentEvents() 記錄所有 socket.emit 呼叫,適合驗證 React action 確實觸發了預期的 server RPC。
多層驗證 — 優先策略
當 user flow 同時涉及 socket + DB + UI(典型 add / remove / rename / fork / close),預設四層全驗:
| 層 | API | 抓的 bug |
|---|
| ① UI | screen.getByText / queryByText | wire 沒接 |
| ② Server 廣播 | claude.receivedEvents('event:name') | handler 沒跑 / payload schema 錯 |
| ③ Store / DB | container.get(TYPES.ProjectStore).getByPath(...) | store / fan-out / transaction |
| ④ Client state | state probe / queryByText 反射 | 訂閱沒接、setState 漏 |
不要只驗 UI。FakeSummoner 本來就給你真 pipeline,每層都查得到 → 多驗一層幾乎免費。
完整範例 + 反向驗證(reject 路徑)見 references/fake-patterns.md 的 Pattern 3.5: 多層驗證。
不適用情境
| 情境 | 跳過哪層 |
|---|
| 純 UI primitive(Button / Dialog) | ②③ |
| 非 socket 事件流 | ② |
| 不寫 DB 的 action(純 UI state) | ③ |
慣例
| 情境 | 採用 |
|---|
| 取得 fake socket + handlers | createFakeSummoner() + renderWithChannel + segment emitSegment |
| Fake socket 物件 | FakeSummoner 提供的 dual-emitter FakeSocket |
| 驗證 action 發 socket event | summoner.sentEvents('event') 或完整 pipeline + claude.received(...) |
包 claude.emitSegment() flush state | await act(async () => claude.emitSegment(...)) |
skipInit: true 模式 | 搭配 cwd prop 由外部 launch |
renderHook 重複 render | wrapper 用 useRef lazy init summoner |
| 攔截 ACK 測中間狀態 | summoner.holdEmit(event) → held.release() |
相關 skill
- Server 端 test harness →
fake-summoner-server
- Test double 層級(client)→
frontend-testing
- RTL query / userEvent 慣例 →
frontend-testing / testing-best-practices
- Storybook play function →
storybook-component