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",
|
"name": "thothii-backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -478,6 +479,26 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -1389,6 +1410,22 @@
|
|||||||
"toad-cache": "^3.7.0"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
|||||||
+10
-2
@@ -8,6 +8,14 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"start": "node dist/server.js"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": { "fastify": "^5.0.0" },
|
"dependencies": {
|
||||||
"devDependencies": { "typescript": "^5.6.0", "tsx": "^4.19.0", "vitest": "^2.1.0", "@types/node": "^22.0.0" }
|
"@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 Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
import type { AppConfig } from "./config.js";
|
import type { AppConfig } from "./config.js";
|
||||||
import { ThtRunner } from "./tht/tht-runner.js";
|
import { ThtRunner } from "./tht/tht-runner.js";
|
||||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||||
@@ -17,6 +18,9 @@ export interface BuildAppDeps {
|
|||||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||||
const app = Fastify({ logger: false });
|
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({
|
const tht = deps?.thtRunner ?? new ThtRunner({
|
||||||
thtBin: config.thtBin,
|
thtBin: config.thtBin,
|
||||||
harnessDir: config.harnessDir,
|
harnessDir: config.harnessDir,
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager;
|
|||||||
app.get("/sessions/:id/events", (req, reply) => {
|
app.get("/sessions/:id/events", (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
const rt = d.mgr.get(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 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);
|
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
|
||||||
req.raw.on("close", off);
|
req.raw.on("close", off);
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@testing-library/user-event": "^14.5.0",
|
||||||
@@ -1788,6 +1789,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -8298,6 +8315,53 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/points-on-curve": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.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`);
|
const es = new EventSource(`${BASE}/sessions/${sessionId}/events`);
|
||||||
es.onopen = () => setConnected(true);
|
es.onopen = () => setConnected(true);
|
||||||
es.onerror = () => setConnected(false);
|
es.onerror = () => setConnected(false);
|
||||||
es.onmessage = (ev) => {
|
|
||||||
|
const handle = (ev: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||||
} catch {
|
} 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 () => {
|
return () => {
|
||||||
|
for (const name of namedEvents) es.removeEventListener(name, handle);
|
||||||
es.close();
|
es.close();
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export class FakeEventSource {
|
|||||||
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
|
||||||
|
addEventListener(_type: string, _handler: unknown) {}
|
||||||
|
removeEventListener(_type: string, _handler: unknown) {}
|
||||||
close() {
|
close() {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user