Skip to main content

fake-summoner-client

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.

Jump to install

Source facts

Repository
recca0120/code-quest
Last source activity
May 6, 2026 at 04:20
Detected SKILL.md language
Chinese
Stars
11
Forks
2

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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()`。 ```tsx 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(); ``` ### 選項 ```tsx await renderWithChannel(<MyComponent />, { channelId: 'ch-test', skipInit: false, // false = 自動 initialize extraSegments: [ // 額外 init-time segments s.controlResponse('init', { models: [{ value: 'opus-4-6' }] }), ], initialState: { // ChannelProvider 的 initial state 注入 pendingControls: [{ requestId: 'r1', subtype: 'can_use_tool', toolName: 'Bash' }], }, launchOnMount: false, // true = React 驅動 session:launch cwd: '/test/cwd', }); ``` `skipInit: true` 用於外部控制啟動流程(搭配 `cwd` prop)。 ## renderWithWorkspace — 多 session 測試 整個 WorkspaceLayout 入口(含 project list / tab bar)。 ```tsx 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(); // 另開 tab 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: ```tsx await user.click(screen.getByRole('button', { name: 'Submit' })); // ✓ 已包 act await act(async () => { await claude.emitSegment(s.assistant('response')); // ⚠️ 必須手動 act }); ``` **便利 helper**(專案內): - `sendUserMessage(user, 'text')` — 打字 + Enter - `emitAssistantTurn(claude, 'msg')` — 自動包 assistant + result + act ## Permission / Control request UI 測試 ```tsx 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 ```tsx await renderWithChannel(<ChatPanel />, { initialState: { pendingControls: [{ requestId: 'r1', subtype: 'can_use_tool', toolName: 'Bash' }], }, }); expect(screen.getByText(/Bash/)).toBeInTheDocument(); ``` ## holdEmit — 測試 loading/connecting 中間狀態 ```tsx const { summoner, claude } = await renderWithChannel(<ChatPanel />); // 攔截 session:join 的 ACK — UI 會卡在 connecting 狀態 const held = summoner.holdEmit('session:join'); // 觸發 join... // 此時 UI 應顯示 loading indicator expect(screen.getByText('Connecting...')).toBeInTheDocument(); // 釋放 ACK held.release(); await waitFor(() => expect(screen.queryByText('Connecting...')).not.toBeInTheDocument()); ``` ## 跨 Context 測試(WorktreeContext / SessionContext) 需要 prime 服務(例如 git worktree list)時,先用**一個 summoner** 設好 state,再給**另一個 summoner** 連上: ```tsx 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' }); // 第二個 summoner 讀到已設好的 state 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 的行為: ```tsx const { addProject, user } = await renderWithWorkspace(); const projA = await addProject({ path: '/a' }); const projB = await addProject({ path: '/b' }); await projA.launchSession(); await projB.launchSession(); // 在 Tab B 打字,切回 Tab A 應保留 Tab A 的內容 await user.type(screen.getByPlaceholderText(...), 'B message'); await user.click(screen.getByRole('tab', { name: /a/i })); expect(screen.queryByDisplayValue('B message')).not.toBeInTheDocument(); ``` ## 模擬 server 主動推送 ```ts // ✅ 用 claude.pushServerEvent — 高層 API act(() => { claude.pushServerEvent('projects:added', { id: '...', path: '/x', name: 'x', pinned: false, color: null, lastOpenedAt: '...', createdAt: '...', }); }); await waitFor(() => expect(state.projects).toHaveLength(1)); // 快捷 API 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) ```ts // 驗證 React 層送了哪些 RPC 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`
View on GitHub