test(frontend): Playwright e2e F1 vs backend+fake-pi (hermetic)

Adds a real-browser Playwright e2e of the full F1 disambiguation loop,
driven against the real backend + fake binaries (no VPN, no real Pi, no Python).

- frontend/e2e/fixtures/fake-tht.mjs: stubs tht CLI (session new/list/show)
- frontend/e2e/fixtures/fake-pi.mjs: wraps harness fake-pi-rpc with f1_disambiguation.json
- frontend/playwright.config.ts: two webServer entries (backend:8799, frontend:5199)
- frontend/e2e/f1.spec.ts: open app → create session → wait for SelectWidget → respond

Bug fixes discovered during e2e:
- useSessionStream: add addEventListener for named SSE events (backend sends
  'event: ui_request' etc.; onmessage only fires for unnamed 'event: message')
- FakeEventSource: add no-op addEventListener/removeEventListener stubs
- backend SSE route: add CORS headers manually in writeHead() since
  reply.raw bypasses the @fastify/cors onSend hook
- backend: install and register @fastify/cors for all non-SSE routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 13:27:00 +02:00
co-authored by Claude Sonnet 4.6
parent 3dec4c3af0
commit 1171181f9a
13 changed files with 292 additions and 4 deletions
+4
View File
@@ -1,4 +1,5 @@
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import type { AppConfig } from "./config.js";
import { ThtRunner } from "./tht/tht-runner.js";
import { PiProcessManager } from "./pi/pi-process-manager.js";
@@ -17,6 +18,9 @@ export interface BuildAppDeps {
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
const app = Fastify({ logger: false });
// Allow any origin in dev/e2e; tighten in production via config if needed.
app.register(cors, { origin: true, credentials: true });
const tht = deps?.thtRunner ?? new ThtRunner({
thtBin: config.thtBin,
harnessDir: config.harnessDir,
+10 -1
View File
@@ -44,7 +44,16 @@ export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager;
app.get("/sessions/:id/events", (req, reply) => {
const id = (req.params as any).id;
const rt = d.mgr.get(id);
reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
// (where @fastify/cors injects headers), so we must set them explicitly here.
const origin = (req.headers.origin as string | undefined) ?? "*";
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
});
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
req.raw.on("close", off);