diff --git a/backend/package-lock.json b/backend/package-lock.json index 42db06df..37d3296a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 92b32ae4..0ebaeabc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" + } } diff --git a/backend/src/app.ts b/backend/src/app.ts index e5de0404..3e5a5dc1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index ef011ba2..c64c48ed 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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); diff --git a/frontend/.gitignore b/frontend/.gitignore index 5241d614..55460121 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -5,3 +5,6 @@ dist-ssr *.local .env .env.local +# Playwright +test-results/ +playwright-report/ diff --git a/frontend/e2e/f1.spec.ts b/frontend/e2e/f1.spec.ts new file mode 100644 index 00000000..16d0dedf --- /dev/null +++ b/frontend/e2e/f1.spec.ts @@ -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); +}); diff --git a/frontend/e2e/fixtures/fake-pi.mjs b/frontend/e2e/fixtures/fake-pi.mjs new file mode 100755 index 00000000..74d329fe --- /dev/null +++ b/frontend/e2e/fixtures/fake-pi.mjs @@ -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); diff --git a/frontend/e2e/fixtures/fake-tht.mjs b/frontend/e2e/fixtures/fake-tht.mjs new file mode 100755 index 00000000..725cb79f --- /dev/null +++ b/frontend/e2e/fixtures/fake-tht.mjs @@ -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 [args...] +// So we skip the first two args after node (-c ). + +const argv = process.argv.slice(2); // drop "node" + script path + +// Strip leading "-c " 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 [--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); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1540eed5..7c335bc6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2fba4bf5..6b064826 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..42a43771 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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", + }, + }, + ], +}); diff --git a/frontend/src/stream/useSessionStream.ts b/frontend/src/stream/useSessionStream.ts index d3d30742..982cdf98 100644 --- a/frontend/src/stream/useSessionStream.ts +++ b/frontend/src/stream/useSessionStream.ts @@ -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); }; diff --git a/frontend/src/test/fakeEventSource.ts b/frontend/src/test/fakeEventSource.ts index ba4e7089..dff52c52 100644 --- a/frontend/src/test/fakeEventSource.ts +++ b/frontend/src/test/fakeEventSource.ts @@ -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; }