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:
Generated
+37
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "thothii-backend",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -478,6 +479,26 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -1389,6 +1410,22 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
||||
+10
-2
@@ -8,6 +8,14 @@
|
||||
"test": "vitest run",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": { "fastify": "^5.0.0" },
|
||||
"devDependencies": { "typescript": "^5.6.0", "tsx": "^4.19.0", "vitest": "^2.1.0", "@types/node": "^22.0.0" }
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,3 +5,6 @@ dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E F1 loop — hermetic (no VPN, no real Pi, no real Python).
|
||||
*
|
||||
* The backend is wired to:
|
||||
* - fake-tht.mjs : stubs `tht session new/list/show`
|
||||
* - fake-pi.mjs : delegates to harness/tests/fake_pi/fake_pi_rpc.mjs
|
||||
* which emits the f1_disambiguation.json scenario (select widget with
|
||||
* "interpretazione A" / "interpretazione B").
|
||||
*
|
||||
* Flow: open app → open NewSessionDialog → fill question → submit (Crea)
|
||||
* → wait for F1 select widget → click an option → assert no error shown.
|
||||
*/
|
||||
test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open the new-session dialog.
|
||||
await page.getByRole("button", { name: /nuova/i }).click();
|
||||
|
||||
// Fill in the question.
|
||||
await page.getByLabel(/domanda/i).fill("quante cardioversioni nel 2024");
|
||||
|
||||
// Submit — the backend creates the session (fake-tht) and spawns Pi (fake-pi).
|
||||
await page.getByRole("button", { name: /^crea$/i }).click();
|
||||
|
||||
// Wait for the F1 disambiguation select widget to appear.
|
||||
// The fake-pi emits extension_ui_request → backend bridges to SSE → frontend
|
||||
// renders SelectWidget with options from f1_disambiguation.json.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /interpretazione A/i }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Both options from the scenario should be present.
|
||||
await expect(page.getByRole("button", { name: /interpretazione B/i })).toBeVisible();
|
||||
|
||||
// Click an option — the frontend POSTs to /sessions/e2e-1/response.
|
||||
await page.getByRole("button", { name: /interpretazione A/i }).click();
|
||||
|
||||
// After responding, the widget should disappear (clearPending) and no error shown.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /interpretazione A/i }),
|
||||
).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("body")).not.toContainText(/errore/i);
|
||||
});
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// fake-pi.mjs — hermetic PI_BIN stub for e2e tests.
|
||||
// Ignores all argv (the backend passes --mode rpc --approve) and
|
||||
// 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";
|
||||
|
||||
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");
|
||||
const result = spawnSync(process.execPath, [fakePiRpc, scenario], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env },
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
// fake-tht.mjs — hermetic THT_BIN stub for e2e tests.
|
||||
// Parses argv and emits the minimal JSON the backend expects.
|
||||
// The backend's ThtRunner prepends: thtBin -c <config> <subcommand> [args...]
|
||||
// So we skip the first two args after node (-c <configPath>).
|
||||
|
||||
const argv = process.argv.slice(2); // drop "node" + script path
|
||||
|
||||
// Strip leading "-c <value>" pair (ThtRunner always prepends this).
|
||||
let args = argv;
|
||||
if (args[0] === "-c") {
|
||||
args = args.slice(2);
|
||||
}
|
||||
|
||||
// args now starts with the subcommand group, e.g. ["session","new","...","--json"]
|
||||
const sub = args.slice(0, 2).join(" ");
|
||||
|
||||
if (sub === "session new") {
|
||||
// session new <question> [--provider p] [--model m] [--thinking t] [--name n] --json
|
||||
process.stdout.write(JSON.stringify({ id: "e2e-1" }) + "\n");
|
||||
} else if (sub === "session list") {
|
||||
process.stdout.write(JSON.stringify([]) + "\n");
|
||||
} else if (sub === "session show") {
|
||||
const id = args[2] ?? "e2e-1";
|
||||
process.stdout.write(
|
||||
JSON.stringify({ id, status: "open", provider: null, model: null, thinking: null }) + "\n",
|
||||
);
|
||||
} else if (sub === "sql preview") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ columns: [], rows: [], execution_ms: 0, truncated: false }) + "\n",
|
||||
);
|
||||
} else {
|
||||
// Unknown subcommand — exit 0 with empty JSON so the backend doesn't crash.
|
||||
process.stdout.write("{}\n");
|
||||
}
|
||||
process.exit(0);
|
||||
Generated
+64
@@ -27,6 +27,7 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
@@ -1788,6 +1789,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -8298,6 +8315,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/points-on-curve": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const __dir = new URL(".", import.meta.url).pathname;
|
||||
const fakePi = resolve(__dir, "e2e/fixtures/fake-pi.mjs");
|
||||
const fakeTht = resolve(__dir, "e2e/fixtures/fake-tht.mjs");
|
||||
const harnessDir = resolve(__dir, "..", "harness");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 60_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
baseURL: "http://localhost:5199",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
// Backend — wired to both fakes; no VPN/Pi/Python required.
|
||||
command: "npm run dev",
|
||||
cwd: resolve(__dir, "..", "backend"),
|
||||
url: "http://localhost:8799/health",
|
||||
timeout: 30_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
PI_BIN: fakePi,
|
||||
THT_BIN: fakeTht,
|
||||
THT_HARNESS_DIR: harnessDir,
|
||||
PORT: "8799",
|
||||
AUTH_MODE: "none",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Frontend dev server pointing at the hermetic backend.
|
||||
command: "npm run dev -- --port 5199",
|
||||
cwd: __dir,
|
||||
url: "http://localhost:5199",
|
||||
timeout: 30_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
VITE_BACKEND_URL: "http://localhost:8799",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -13,7 +13,8 @@ export function useSessionStream(sessionId: string | null) {
|
||||
const es = new EventSource(`${BASE}/sessions/${sessionId}/events`);
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
es.onmessage = (ev) => {
|
||||
|
||||
const handle = (ev: MessageEvent) => {
|
||||
try {
|
||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||
} catch {
|
||||
@@ -21,7 +22,14 @@ export function useSessionStream(sessionId: string | null) {
|
||||
}
|
||||
};
|
||||
|
||||
// onmessage catches unnamed events; addEventListener catches named events
|
||||
// (the backend sends event: ui_request, event: text_delta, etc.)
|
||||
es.onmessage = handle;
|
||||
const namedEvents = ["ui_request", "text_delta", "info", "system_event"] as const;
|
||||
for (const name of namedEvents) es.addEventListener(name, handle);
|
||||
|
||||
return () => {
|
||||
for (const name of namedEvents) es.removeEventListener(name, handle);
|
||||
es.close();
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,9 @@ export class FakeEventSource {
|
||||
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) {}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user