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:
@@ -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);
|
||||
Reference in New Issue
Block a user