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<string,Set>; 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 13:33:50 +02:00
co-authored by Claude Opus 4.8
parent 1171181f9a
commit ddbbe4d1ac
4 changed files with 31 additions and 11 deletions
+3 -4
View File
@@ -4,15 +4,14 @@
// exec()s the scripted fake-pi-rpc.mjs with the F1 disambiguation scenario. // exec()s the scripted fake-pi-rpc.mjs with the F1 disambiguation scenario.
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; 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 __dir = dirname(fileURLToPath(import.meta.url));
const fakePiRpc = resolve(__dir, "../../..", "harness/tests/fake_pi/fake_pi_rpc.mjs"); const fakePiRpc = resolve(__dir, "../../..", "harness/tests/fake_pi/fake_pi_rpc.mjs");
const scenario = resolve(__dir, "../../..", "harness/tests/fake_pi/scripts/f1_disambiguation.json"); 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. // Run the scripted fake-pi-rpc on this process's stdio so the backend's child
// On Node.js we use spawnSync with stdio inherit to approximate exec(). // handle (stdin/stdout) drives the JSONL protocol directly.
const { spawnSync } = await import("node:child_process");
const result = spawnSync(process.execPath, [fakePiRpc, scenario], { const result = spawnSync(process.execPath, [fakePiRpc, scenario], {
stdio: "inherit", stdio: "inherit",
env: { ...process.env }, env: { ...process.env },
+3 -2
View File
@@ -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 // Wait for the session to be created and SSE to connect
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); 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(() => act(() =>
FakeEventSource.instances[0].emit({ FakeEventSource.instances[0].emitNamed("ui_request", {
type: "ui_request", type: "ui_request",
ui_request: { ui_request: {
id: "u1", id: "u1",
+11 -2
View File
@@ -10,16 +10,25 @@ beforeEach(() => {
useSessionStore.getState().resetSession(); 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")); renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0]; const es = FakeEventSource.instances[0];
expect(es.url).toContain("/sessions/s1/events"); 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(() => 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"); 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", () => { test("closes the stream on unmount", () => {
const { unmount } = renderHook(() => useSessionStream("s1")); const { unmount } = renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0]; const es = FakeEventSource.instances[0];
+14 -3
View File
@@ -4,15 +4,26 @@ export class FakeEventSource {
onopen: (() => void) | null = null; onopen: (() => void) | null = null;
onerror: (() => void) | null = null; onerror: (() => void) | null = null;
closed = false; closed = false;
private listeners = new Map<string, Set<(e: { data: string }) => void>>();
constructor(public url: string) { constructor(public url: string) {
FakeEventSource.instances.push(this); FakeEventSource.instances.push(this);
} }
/** Dispatch to the default onmessage handler (unnamed `event: message`). */
emit(obj: unknown) { emit(obj: unknown) {
this.onmessage?.({ data: JSON.stringify(obj) }); this.onmessage?.({ data: JSON.stringify(obj) });
} }
// no-op stubs so useSessionStream's addEventListener calls don't throw /** Dispatch to handlers registered for a NAMED event (e.g. "ui_request"). */
addEventListener(_type: string, _handler: unknown) {} emitNamed(type: string, obj: unknown) {
removeEventListener(_type: string, _handler: 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() { close() {
this.closed = true; this.closed = true;
} }