From ddbbe4d1ace29ea644039ffb00a792e7694f86fa Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 13:33:50 +0200 Subject: [PATCH] test(frontend): unit tests exercise named SSE events (fidelity) + cleanup FakeEventSource.addEventListener was a no-op, so emit() only drove onmessage. Production relies on addEventListener for the backend's NAMED events (event: ui_request), so the unit tests could pass while prod silently broke. - fakeEventSource: store named handlers in a Map; add emitNamed() that dispatches to them; keep emit() for the unnamed/default onmessage path - useSessionStream.test: ui_request now driven via emitNamed (production path); add a separate test for the unnamed text_delta path via plain emit - f1-loop.test: widget emission switched to emitNamed("ui_request", ...) - verified: tests FAIL if addEventListener wiring is removed from useSessionStream (then restored) - cleanup: fake-pi.mjs drops unused execFileSync import, uses static spawnSync Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/fixtures/fake-pi.mjs | 7 +++---- frontend/src/shell/f1-loop.test.tsx | 5 +++-- frontend/src/stream/useSessionStream.test.tsx | 13 +++++++++++-- frontend/src/test/fakeEventSource.ts | 17 ++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/frontend/e2e/fixtures/fake-pi.mjs b/frontend/e2e/fixtures/fake-pi.mjs index 74d329fe..e9b5f0dc 100755 --- a/frontend/e2e/fixtures/fake-pi.mjs +++ b/frontend/e2e/fixtures/fake-pi.mjs @@ -4,15 +4,14 @@ // exec()s the scripted fake-pi-rpc.mjs with the F1 disambiguation scenario. import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { execFileSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; const __dir = dirname(fileURLToPath(import.meta.url)); const fakePiRpc = resolve(__dir, "../../..", "harness/tests/fake_pi/fake_pi_rpc.mjs"); const scenario = resolve(__dir, "../../..", "harness/tests/fake_pi/scripts/f1_disambiguation.json"); -// exec replaces the current process so the backend's child handle stays valid. -// On Node.js we use spawnSync with stdio inherit to approximate exec(). -const { spawnSync } = await import("node:child_process"); +// Run the scripted fake-pi-rpc on this process's stdio so the backend's child +// handle (stdin/stdout) drives the JSONL protocol directly. const result = spawnSync(process.execPath, [fakePiRpc, scenario], { stdio: "inherit", env: { ...process.env }, diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx index 4e8685e3..22642404 100644 --- a/frontend/src/shell/f1-loop.test.tsx +++ b/frontend/src/shell/f1-loop.test.tsx @@ -41,9 +41,10 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async // Wait for the session to be created and SSE to connect await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); - // Simulate the backend emitting the F1 widget + // Simulate the backend emitting the F1 widget via a NAMED SSE event + // (`event: ui_request`) — the same addEventListener path production uses. act(() => - FakeEventSource.instances[0].emit({ + FakeEventSource.instances[0].emitNamed("ui_request", { type: "ui_request", ui_request: { id: "u1", diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx index bb771048..c77d8fcd 100644 --- a/frontend/src/stream/useSessionStream.test.tsx +++ b/frontend/src/stream/useSessionStream.test.tsx @@ -10,16 +10,25 @@ beforeEach(() => { useSessionStore.getState().resetSession(); }); -test("opens an EventSource for the session and feeds events to the store", () => { +test("opens an EventSource and feeds NAMED events to the store", () => { renderHook(() => useSessionStream("s1")); const es = FakeEventSource.instances[0]; expect(es.url).toContain("/sessions/s1/events"); + // Backend sends `event: ui_request` (named) — drive the addEventListener path + // that production relies on, not the unnamed onmessage fallback. act(() => - es.emit({ type: "ui_request", ui_request: { id: "u1", widget: "select" } }) + es.emitNamed("ui_request", { type: "ui_request", ui_request: { id: "u1", widget: "select" } }) ); expect(useSessionStore.getState().pendingWidget?.id).toBe("u1"); }); +test("also feeds UNNAMED (default message) events to the store", () => { + renderHook(() => useSessionStream("s1")); + const es = FakeEventSource.instances[0]; + act(() => es.emit({ type: "text_delta", text: "hello" })); + expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("hello"); +}); + test("closes the stream on unmount", () => { const { unmount } = renderHook(() => useSessionStream("s1")); const es = FakeEventSource.instances[0]; diff --git a/frontend/src/test/fakeEventSource.ts b/frontend/src/test/fakeEventSource.ts index dff52c52..2865d087 100644 --- a/frontend/src/test/fakeEventSource.ts +++ b/frontend/src/test/fakeEventSource.ts @@ -4,15 +4,26 @@ export class FakeEventSource { onopen: (() => void) | null = null; onerror: (() => void) | null = null; closed = false; + private listeners = new Map void>>(); constructor(public url: string) { FakeEventSource.instances.push(this); } + /** Dispatch to the default onmessage handler (unnamed `event: message`). */ emit(obj: unknown) { this.onmessage?.({ data: JSON.stringify(obj) }); } - // no-op stubs so useSessionStream's addEventListener calls don't throw - addEventListener(_type: string, _handler: unknown) {} - removeEventListener(_type: string, _handler: unknown) {} + /** Dispatch to handlers registered for a NAMED event (e.g. "ui_request"). */ + emitNamed(type: string, obj: unknown) { + const data = JSON.stringify(obj); + for (const h of this.listeners.get(type) ?? []) h({ data }); + } + addEventListener(type: string, handler: (e: { data: string }) => void) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(handler); + } + removeEventListener(type: string, handler: (e: { data: string }) => void) { + this.listeners.get(type)?.delete(handler); + } close() { this.closed = true; }