diff --git a/.gitignore b/.gitignore index 9f00bf1..a0b04f6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist *.db .pi/ daemon/.pi-scratch/ +daemon/lvmh-daemon diff --git a/Makefile b/Makefile index 4708853..9162d6a 100644 --- a/Makefile +++ b/Makefile @@ -2,13 +2,14 @@ SHELL := /bin/bash REMOTE ?= alarm REMOTE_DIR ?= /zdata/root/dockerFiles/lvmh -.PHONY: help build verify worker-image daemon web test deploy e2e clean +.PHONY: help build verify worker-image daemon web test deploy e2e integration e2e-integration clean help: @echo "build build daemon + web (dev checks)" @echo "verify vet + staticcheck + tests + web build" @echo "worker-image build lvmh-worker:latest (requires plugin/)" @echo "deploy rsync to alarm:$(REMOTE_DIR) and docker compose up -d" + @echo "integration cross-component e2e harness (e2e/, requires go + node >= 22)" build: daemon web @@ -40,5 +41,10 @@ deploy: verify worker-image e2e: bash deploy/e2e.sh +integration: + node e2e/driver.mjs + +e2e-integration: integration + clean: rm -rf web/dist web/node_modules daemon/lvmh-daemon .pi/scratch diff --git a/daemon/api.go b/daemon/api.go index b52e255..4b6ae8a 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -49,7 +49,7 @@ func NewServer(store *Store, hub *Hub, spawn *Spawner, gl *GitLab) *Server { func (s *Server) Routes(webdist string) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /agent/ws", s.hub.ServeAgentWS) + mux.Handle("GET /agent/ws", s.bearerAuth(http.HandlerFunc(s.hub.ServeAgentWS))) mux.HandleFunc("GET /ws", s.handleWebWS) api := http.NewServeMux() diff --git a/daemon/lvmh-daemon b/daemon/lvmh-daemon deleted file mode 100755 index 1d9a118..0000000 Binary files a/daemon/lvmh-daemon and /dev/null differ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..9ae4414 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,58 @@ +# lvmh e2e integration harness + +Cross-component tests proving the seams between the three **real** components: + +- **daemon** — the golang server, booted with `go run .` from `../daemon` + (real HTTP server, real sqlite store, real WS hubs) +- **web dist** — the production `web/dist` build served BY the daemon via + `--webdist` (built first if missing; `npm install` too when `node_modules` + is absent) +- **plugin client** — a scripted websocket client speaking exactly the + protocol the real `plugin/lvmh-agent.ts` speaks (PROTOCOL.md `v: 1`, + flattened envelopes, `Authorization: Bearer …` on the upgrade) — plus a fake + GitLab v4 (`/api/v4/user`, `/api/v4/projects`) on an ephemeral port. + +No Docker, no LLM key, no real GitLab needed. Zero npm dependencies +(Node built-ins only; the global `WebSocket` client, Node ≥ 22). + +## Run + +```sh +make integration # or: make e2e-integration, or: node e2e/driver.mjs +``` + +Prerequisites: `go` (toolchain for `go run .`) and `node >= 22`. +First run compiles the daemon and may build the web dist; later runs reuse +both. Exit code is non-zero when any check fails. `LVMH_E2E_KEEP=1` keeps the +harness scratch dir (daemon log + db) under `.pi/scratch/e2e-*/` for debugging. + +## What is covered + +| scenario | proves | +| --- | --- | +| `auth` | REST 401 without/with wrong bearer; web `/ws?token=` rejected before upgrade. Agent-WS rejection is **xfail**: `daemon Routes()` serves `/agent/ws` without `bearerAuth` (protocol §Auth violation, real bug — see check reason). | +| `web-dist` | daemon serves the real built UI: `GET /` index, hashed `/assets/*`, `manifest.webmanifest`, SPA fallback. | +| `agent-lifecycle` | hello → `welcome{lastSeq:0}`, persisted events, `GET /api/sessions` online flag + snapshot fields, offline after disconnect, `events?after=` slicing, envelope re-serialization. | +| `replay` | reconnect `welcome.lastSeq` = persisted high-water; seq continuation; `message_update` fanned out live but **never persisted**; REST events unaffected. | +| `prompt-routing` | browser `/ws` subscribe → batched live `events` frames incl. `message_update`; `POST /api/sessions/:id/prompt` → `prompt` frame on the agent WS (promptId, envelope); 409 when offline. | +| `session-list` | `session_list` broadcasts to every web client on session change (second subscriber included), online flag flips on disconnect. | +| `gitlab` | `POST /api/gitlab/connect` against the fake GitLab (PAT forwarded as `Private-Token`), `repos` mapping + daemon-side sort by activity, `status`, PAT never present in any response, failed connect does not clobber the stored PAT. | +| `spawn-validation` | `POST /api/spawn` rejects non `group/project` repos and malformed JSON with 400; `/api/spawn/status` shape. (A valid spawn needs Docker and is intentionally out of scope.) | +| `resilience` | SIGKILL of the daemon process group + reboot on the same DB: sessions listed `online:false`, every persisted event intact, `welcome.lastSeq` honoured, seq continues past the high-water. | + +## Layout + +``` +driver.mjs boots daemon + fake gitlab, runs scenarios, summary, exit code +lib.mjs helpers: deadline polling, REST client, scripted WS clients +fake-gitlab.mjs minimal GitLab v4 on an ephemeral port +scenarios/*.mjs one module per scenario (assert-based checks) +``` + +Determinism: ephemeral ports everywhere, all waits are deadline-bounded polls +(`waitUntil` / frame waiters), never fixed sleeps. The daemon runs in its own +process group and is always killed on exit (including on crash or SIGINT). + +`xfail` entries are known-broken assertions (documented real bugs): they do not +fail the run; if one starts passing it is reported as `xpass` so the xfail can +be dropped. diff --git a/e2e/driver.mjs b/e2e/driver.mjs new file mode 100644 index 0000000..6bd384f --- /dev/null +++ b/e2e/driver.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// driver.mjs — lvmh cross-integration harness. +// +// Proves the seams between the three REAL components: +// • daemon — the golang binary, booted via `go run .` from ../daemon +// • web — the built web/dist served BY the daemon (--webdist) +// • plugin — a scripted WS client speaking exactly the protocol the real +// plugin speaks (PROTOCOL.md v1, flattened envelopes, bearer +// header on upgrade — see plugin/lvmh-agent.ts) +// +// Plus a fake GitLab v4 on an ephemeral port (GITLAB_BASE_URL). +// Deterministic: ephemeral ports, deadline-bounded polling, no fixed sleeps. +// Zero npm dependencies (Node >= 22). +// +// Exit code: 0 green, 1 any failed check. Daemon process group is always +// killed on exit (normal, crash, SIGINT/SIGTERM). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { startFakeGitLab } from "./fake-gitlab.mjs"; +import { + DAEMON_DIR, + REPO_ROOT, + Results, + TEST_TOKEN, + WEB_DIST, + ensureWebDist, + freePort, + startDaemon, +} from "./lib.mjs"; + +import { run as auth } from "./scenarios/auth.mjs"; +import { run as webDist } from "./scenarios/web-dist.mjs"; +import { run as agentLifecycle } from "./scenarios/agent-lifecycle.mjs"; +import { run as replay } from "./scenarios/replay.mjs"; +import { run as promptRouting } from "./scenarios/prompt-routing.mjs"; +import { run as sessionList } from "./scenarios/session-list.mjs"; +import { run as gitlab } from "./scenarios/gitlab.mjs"; +import { run as spawnValidation } from "./scenarios/spawn-validation.mjs"; +import { run as resilience } from "./scenarios/resilience.mjs"; + +const SCENARIOS = [ + ["auth", auth], + ["web-dist", webDist], + ["agent-lifecycle", agentLifecycle], + ["replay", replay], + ["prompt-routing", promptRouting], + ["session-list", sessionList], + ["gitlab", gitlab], + ["spawn-validation", spawnValidation], + ["resilience", resilience], // last: SIGKILLs and reboots the daemon +]; + +const r = new Results(); +let daemon = null; +let fakeGitLab = null; +let tmpDir = null; +let cleanedUp = false; + +async function cleanup() { + if (cleanedUp) return; + cleanedUp = true; + if (daemon) await daemon.stop("SIGKILL"); + if (fakeGitLab) fakeGitLab.close(); + if (tmpDir && process.env.LVMH_E2E_KEEP !== "1") { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } else if (tmpDir) { + console.log(`(kept harness artifacts: ${tmpDir})`); + } +} + +process.on("SIGINT", () => { + void cleanup().finally(() => process.exit(130)); +}); +process.on("SIGTERM", () => { + void cleanup().finally(() => process.exit(143)); +}); + +async function main() { + console.log("=== lvmh e2e integration ==="); + const built = ensureWebDist(); + console.log(`web dist: ${built ? "built now" : "reused"} ${path.relative(REPO_ROOT, WEB_DIST)}`); + + fs.mkdirSync(path.join(REPO_ROOT, ".pi", "scratch"), { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, ".pi", "scratch", "e2e-")); + const dbPath = path.join(tmpDir, "lvmh.db"); + const logPath = path.join(tmpDir, "daemon.log"); + + fakeGitLab = await startFakeGitLab(); + const port = await freePort(); + const addr = `127.0.0.1:${port}`; + const daemonEnv = { + LVMH_TOKEN: TEST_TOKEN, + LVMH_DB: dbPath, + GITLAB_BASE_URL: fakeGitLab.url, + LVMH_REPO_DIR: path.join(tmpDir, "repos"), + LVMH_CONTAINER_LVMH_URL: `ws://${addr}/agent/ws`, + LVMH_WORKER_DOCKERFILE: path.join(tmpDir, "absent-worker.Dockerfile"), + }; + const boot = () => startDaemon({ addr, dbPath, webdist: WEB_DIST, logPath, env: daemonEnv }); + daemon = await boot(); + + console.log(`daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`); + console.log(`fake gitlab: ${fakeGitLab.url}`); + + const ctx = { + r, + state: {}, + base: daemon.baseUrl, + token: TEST_TOKEN, + agentUrl: daemon.agentUrl, + webUrl: daemon.webUrl, + gitlab: fakeGitLab, + restartDaemon: async () => { + await daemon.stop("SIGKILL"); + daemon = await boot(); + }, + }; + + for (const [name, scenario] of SCENARIOS) { + r.group(name); + try { + await scenario(ctx); + } catch (err) { + r.check(`${name}: scenario crashed`, false, String(err?.stack ?? err)); + const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8").split("\n").slice(-15).join("\n") : ""; + if (log) console.log(` daemon.log tail:\n${log.split("\n").map((l) => " " + l).join("\n")}`); + } + } + + const { ok, fail, xfail, xpass, total } = r.summary(); + console.log("\n──────────────────────────────"); + console.log(`passed ${ok} / ${total} failed ${fail} xfail ${xfail} (known bugs) xpass ${xpass}`); + if (fail > 0) { + console.log("failed checks:"); + for (const e of r.entries.filter((e) => e.status === "fail")) { + console.log(` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`); + } + } + console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`); + process.exitCode = fail === 0 ? 0 : 1; +} + +main() + .catch((err) => { + console.error("harness crashed:", err); + process.exitCode = 1; + }) + .finally(() => cleanup()); diff --git a/e2e/fake-gitlab.mjs b/e2e/fake-gitlab.mjs new file mode 100644 index 0000000..ddf9c61 --- /dev/null +++ b/e2e/fake-gitlab.mjs @@ -0,0 +1,66 @@ +// fake-gitlab.mjs — minimal GitLab v4 API stand-in on an ephemeral port. +// Serves exactly the two endpoints the daemon calls (daemon/gitlab.go): +// GET /api/v4/user (PAT validation) +// GET /api/v4/projects (member projects listing) +// Projects are returned UNSORTED so the harness proves the daemon sorts by +// last_activity_at. + +import * as http from "node:http"; + +export const GOOD_PAT = "glpat-e2e-0123456789abcdef"; +const HTTP_OK = 200; +const HTTP_UNAUTHORIZED = 401; +const HTTP_NOT_FOUND = 404; + +export function startFakeGitLab() { + const seen = { userAuths: [], projectAuths: [] }; + const server = http.createServer((req, res) => { + const auth = String(req.headers["private-token"] ?? ""); + const send = (code, obj) => { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(obj)); + }; + if (req.url.startsWith("/api/v4/user")) { + seen.userAuths.push(auth); + return auth === GOOD_PAT + ? send(HTTP_OK, { id: 1, username: "e2e-user", name: "E2E User" }) + : send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + } + if (req.url.startsWith("/api/v4/projects")) { + seen.projectAuths.push(auth); + if (auth !== GOOD_PAT) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + const base = `http://127.0.0.1:${server.address().port}`; + return send(HTTP_OK, [ + { + path_with_namespace: "lvmh/beta", + name: "beta", + namespace: { path: "lvmh" }, + last_activity_at: "2025-07-01T10:00:00Z", + web_url: `${base}/lvmh/beta`, + default_branch: "main", + }, + { + path_with_namespace: "lvmh/alpha", + name: "alpha", + namespace: { path: "lvmh" }, + last_activity_at: "2025-08-01T10:00:00Z", + web_url: `${base}/lvmh/alpha`, + default_branch: "trunk", + }, + ]); + } + return send(HTTP_NOT_FOUND, { message: "404 Not Found" }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve({ + url: `http://127.0.0.1:${server.address().port}`, + pat: GOOD_PAT, + seen, + close() { + server.close(); + }, + }); + }); + }); +} diff --git a/e2e/lib.mjs b/e2e/lib.mjs new file mode 100644 index 0000000..3ccd036 --- /dev/null +++ b/e2e/lib.mjs @@ -0,0 +1,324 @@ +// lib.mjs — shared helpers for the lvmh cross-integration harness. +// Zero npm dependencies: Node >= 22 built-ins only (http, ws via global +// WebSocket, child_process, fs, net). + +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const E2E_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(E2E_DIR, ".."); +export const DAEMON_DIR = path.join(REPO_ROOT, "daemon"); +export const WEB_DIST = path.join(REPO_ROOT, "web", "dist"); +export const TEST_TOKEN = "test-token"; + +// Bounded-wait deadlines (ms). All waits poll with a deadline, never fixed sleeps. +export const BOOT_TIMEOUT_MS = 120_000; // first `go run .` may need to compile +export const BUILD_TIMEOUT_MS = 300_000; // npm install + vite build +export const FRAME_WAIT_MS = 5_000; // one WS frame of interest +export const POLL_STEP_MS = 25; +export const KILL_WAIT_MS = 15_000; // daemon process-group teardown +export const CLOSE_WAIT_MS = 5_000; + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// waitUntil polls fn (sync or async) until it returns truthy or the deadline +// passes. Always re-evaluates once after the deadline so a slow-but-successful +// predicate is not reported as a timeout. +export async function waitUntil(fn, timeoutMs = FRAME_WAIT_MS, stepMs = POLL_STEP_MS) { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await fn()) return true; + if (Date.now() >= deadline) return Boolean(await fn()); + await sleep(stepMs); + } +} + +export function sid(tag) { + return `${tag}-${randomUUID().slice(0, 8)}`; +} + +// Results collects check/xfail outcomes; printed live, one line per check. +export class Results { + constructor() { + this.entries = []; + this.scenario = ""; + } + group(name) { + this.scenario = name; + console.log(`\n── ${name} ──`); + } + record(status, name, detail, reason) { + this.entries.push({ scenario: this.scenario, status, name, detail, reason }); + switch (status) { + case "ok": + console.log(`ok ${name}`); + break; + case "fail": + console.log(`FAIL ${name}${detail ? " — " + detail : ""}`); + break; + case "xfail": + console.log(`xfail ${name} [known: ${reason}]${detail ? " — " + detail : ""}`); + break; + case "xpass": + console.log(`xpass ${name} [expected to fail, now passes — drop the xfail]`); + break; + } + } + check(name, ok, detail = "") { + this.record(ok ? "ok" : "fail", name, detail); + } + // xfail: assertion documented as broken upstream. ok=true means the + // underlying behavior unexpectedly works again (xpass), never a failure. + xfail(name, ok, detail = "", reason = "") { + this.record(ok ? "xpass" : "xfail", name, detail, reason); + } + summary() { + const count = (s) => this.entries.filter((e) => e.status === s).length; + return { + ok: count("ok"), + fail: count("fail"), + xfail: count("xfail"), + xpass: count("xpass"), + total: this.entries.length, + }; + } +} + +// rest is a small fetch wrapper: token=null sends no Authorization header. +export async function rest(baseUrl, token, pathname, { method = "GET", body, headers = {} } = {}) { + const h = { ...headers }; + if (token !== null) h.Authorization = `Bearer ${token}`; + const opts = { method, headers: h }; + if (body !== undefined) { + h["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const res = await fetch(baseUrl + pathname, opts); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // non-JSON body (e.g. index.html) — text is still available + } + return { status: res.status, text, json, headers: res.headers }; +} + +// WSSock wraps the global (undici) WebSocket: buffers parsed frames and +// resolves deadline-bounded waiters. +export class WSSock { + constructor(url, wsOptions) { + this.url = url; + this.frames = []; + this.waiters = []; + this.isOpen = false; + this.failed = false; + this.closed = false; + this.ws = new WebSocket(url, wsOptions); + this.ws.onopen = () => { + this.isOpen = true; + }; + this.ws.onerror = () => { + this.failed = true; + }; + this.ws.onclose = () => { + this.closed = true; + for (const w of this.waiters.splice(0)) { + clearTimeout(w.timer); + w.resolve(null); + } + }; + this.ws.onmessage = (ev) => { + let f = null; + try { + f = JSON.parse(ev.data); + } catch { + return; + } + this.frames.push(f); + for (const w of this.waiters.splice(0)) { + if (w.pred(f)) { + clearTimeout(w.timer); + w.resolve(f); + } else { + this.waiters.push(w); + } + } + }; + } + send(obj) { + this.ws.send(JSON.stringify(obj)); + } + // opened resolves once the socket is open, failed or closed. + async opened(timeoutMs = FRAME_WAIT_MS) { + await waitUntil(() => this.isOpen || this.failed || this.closed, timeoutMs); + return this.isOpen; + } + async waitForFrame(pred, timeoutMs = FRAME_WAIT_MS) { + const hit = this.frames.find(pred); + if (hit) return hit; + return new Promise((resolve) => { + const w = { pred, resolve, timer: null }; + w.timer = setTimeout(() => { + const i = this.waiters.indexOf(w); + if (i >= 0) this.waiters.splice(i, 1); + resolve(null); + }, timeoutMs); + this.waiters.push(w); + }); + } + async waitForClose(timeoutMs = CLOSE_WAIT_MS) { + return waitUntil(() => this.closed, timeoutMs); + } + close() { + try { + this.ws.close(); + } catch { + // already dead + } + } +} + +// agentClient connects exactly like the real plugin +// (plugin/lvmh-agent.ts: new WebSocket(url, { headers: { Authorization }})). +export async function agentClient(agentUrl, token) { + const sock = new WSSock(agentUrl, { headers: { Authorization: `Bearer ${token}` } }); + if (!(await sock.opened())) { + throw new Error(`agent WS failed to open: ${agentUrl}`); + } + return sock; +} + +// webClient connects like the browser: token as query param on /ws. +export async function webClient(webUrl, token) { + const sock = new WSSock(`${webUrl}?token=${encodeURIComponent(token)}`); + if (!(await sock.opened())) { + throw new Error(`web WS failed to open: ${webUrl}`); + } + return sock; +} + +// hello/session snapshot per PROTOCOL.md. +export function sessionSnapshot(id, overrides = {}) { + return { + id, + name: null, + cwd: "/work/e2e", + model: "glm-5.3", + provider: "zai-renaud", + agent: false, + repo: null, + startedAt: Date.now(), + ...overrides, + }; +} + +export function agentHello(sock, session) { + sock.send({ + v: 1, + type: "hello", + sessionId: session.id, + seq: 0, + ts: Date.now(), + session, + }); + return sock.waitForFrame((f) => f.type === "welcome"); +} + +// agentEvent sends one agent→daemon frame; payload fields are flattened into +// the envelope (daemon README: "payload fields are flattened into the +// envelope object"). +export function agentEvent(sock, sessionId, seq, type, payload = {}) { + sock.send({ v: 1, sessionId, seq, ts: Date.now(), type, ...payload }); +} + +export async function freePort() { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} + +// startDaemon boots the REAL daemon via `go run .` (detached process group so +// the compiled child dies with it) and waits for /api/sessions readiness. +export async function startDaemon({ addr, dbPath, webdist, logPath, env }) { + const logFd = fs.openSync(logPath, "a"); + const proc = spawn( + "go", + ["run", ".", "--addr", addr, "--db", dbPath, "--webdist", webdist], + { + cwd: DAEMON_DIR, + env: { ...process.env, ...env }, + detached: true, + stdio: ["ignore", logFd, logFd], + }); + const ctl = { + baseUrl: `http://${addr}`, + agentUrl: `ws://${addr}/agent/ws`, + webUrl: `ws://${addr}/ws`, + pid: proc.pid, + async stop(signal = "SIGTERM") { + if (proc.exitCode === null) { + try { + process.kill(-proc.pid, signal); // whole group: go run + binary + } catch { + // already gone + } + } + await new Promise((resolve) => { + if (proc.exitCode !== null) return resolve(); + const t = setTimeout(resolve, KILL_WAIT_MS); + proc.on("exit", () => { + clearTimeout(t); + resolve(); + }); + }); + try { + fs.closeSync(logFd); + } catch { + // already closed + } + }, + }; + const ready = await waitUntil(async () => { + try { + const res = await fetch(`${ctl.baseUrl}/api/sessions`, { + headers: { Authorization: `Bearer ${env.LVMH_TOKEN}` }, + }); + return res.status === 200; + } catch { + return false; + } + }, BOOT_TIMEOUT_MS, 100); + if (!ready) { + await ctl.stop("SIGKILL"); + throw new Error(`daemon not ready on ${addr} within ${BOOT_TIMEOUT_MS}ms — see ${logPath}`); + } + return ctl; +} + +// ensureWebDist builds web/dist (npm install first when node_modules absent). +export function ensureWebDist() { + if (fs.existsSync(path.join(WEB_DIST, "index.html"))) return false; + const webDir = path.join(REPO_ROOT, "web"); + const run = (args) => + spawnSync(args[0], args.slice(1), { cwd: webDir, stdio: "inherit", timeout: BUILD_TIMEOUT_MS }); + if (!fs.existsSync(path.join(webDir, "node_modules"))) { + const inst = run(["npm", "install", "--no-fund", "--no-audit"]); + if (inst.status !== 0) throw new Error("npm install for web failed"); + } + const build = run(["npm", "run", "build"]); + if (build.status !== 0 || !fs.existsSync(path.join(WEB_DIST, "index.html"))) { + throw new Error("web build failed"); + } + return true; +} diff --git a/e2e/scenarios/agent-lifecycle.mjs b/e2e/scenarios/agent-lifecycle.mjs new file mode 100644 index 0000000..a15fb92 --- /dev/null +++ b/e2e/scenarios/agent-lifecycle.mjs @@ -0,0 +1,78 @@ +// scenarios/agent-lifecycle.mjs — full plugin lifecycle against the real +// daemon: hello→welcome, persisted events, online flag, events after disconnect. + +import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, agentUrl } = ctx; + const id = sid("e2e-life"); + const snapshot = sessionSnapshot(id); + + const agent = await agentClient(agentUrl, token); + const welcome = await agentHello(agent, snapshot); + r.check("hello → welcome received", welcome !== null && welcome.type === "welcome"); + r.check("welcome echoes sessionId", welcome?.sessionId === id, JSON.stringify(welcome ?? null)); + r.check("welcome lastSeq 0 on fresh session", welcome?.lastSeq === 0, `got ${welcome?.lastSeq}`); + r.check("welcome envelope v=1", welcome?.v === 1); + + agentEvent(agent, id, 1, "message_end", { + message: { role: "user", id: "m1", text: "hello from e2e", thinking: null, toolCalls: [], toolCallId: null }, + }); + agentEvent(agent, id, 2, "tool_execution_start", { toolCallId: "tc1", toolName: "bash", args: { command: "ls" } }); + agentEvent(agent, id, 3, "tool_execution_end", { toolCallId: "tc1", toolName: "bash", isError: false, resultPreview: "file1\nfile2" }); + agentEvent(agent, id, 4, "agent_end", { usage: { inputTokens: 10, outputTokens: 5, totalCost: 0.25 } }); + + const listUrl = "/api/sessions"; + const online = await waitUntil(async () => { + const res = await rest(base, token, listUrl); + return res.json?.find((s) => s.id === id)?.online === true; + }); + r.check("GET /api/sessions shows online=true while agent connected", online); + + const listed = (await rest(base, token, listUrl)).json.find((s) => s.id === id); + r.check( + "session snapshot fields exposed", + listed?.cwd === "/work/e2e" && + listed?.model === "glm-5.3" && + listed?.provider === "zai-renaud" && + listed?.agent === false && + listed?.repo === null && + typeof listed?.startedAt === "number" && + typeof listed?.lastEventAt === "number", + JSON.stringify(listed ?? null), + ); + + agent.close(); + const offline = await waitUntil(async () => { + const res = await rest(base, token, listUrl); + return res.json?.find((s) => s.id === id)?.online === false; + }); + r.check("online=false after agent disconnect", offline); + + const eventsUrl = `/api/sessions/${id}/events?after=0`; + const persisted = await waitUntil( + async () => (await rest(base, token, eventsUrl)).json?.length === 4, + ); + r.check("all 4 persisted events served after=0", persisted); + const ev = (await rest(base, token, eventsUrl)).json ?? []; + r.check( + "event seqs ascending [1,2,3,4]", + JSON.stringify(ev.map((e) => e.seq)) === "[1,2,3,4]", + JSON.stringify(ev.map((e) => e.seq)), + ); + r.check( + "event types in order", + JSON.stringify(ev.map((e) => e.type)) === + JSON.stringify(["message_end", "tool_execution_start", "tool_execution_end", "agent_end"]), + JSON.stringify(ev.map((e) => e.type)), + ); + r.check( + "persisted frames re-serialized with envelope", + ev[0]?.v === 1 && ev[0]?.sessionId === id && typeof ev[0]?.ts === "number" && ev[0]?.message?.text === "hello from e2e", + JSON.stringify(ev[0] ?? null), + ); + const tail = (await rest(base, token, `/api/sessions/${id}/events?after=2`)).json ?? []; + r.check("events?after=2 returns only seq≥3", JSON.stringify(tail.map((e) => e.seq)) === "[3,4]", JSON.stringify(tail.map((e) => e.seq))); + + ctx.state.lifecycle = { id, snapshot, lastSeq: 4 }; +} diff --git a/e2e/scenarios/auth.mjs b/e2e/scenarios/auth.mjs new file mode 100644 index 0000000..77d1fcd --- /dev/null +++ b/e2e/scenarios/auth.mjs @@ -0,0 +1,42 @@ +// scenarios/auth.mjs — bearer auth seams: REST 401s, WS rejection. +// PROTOCOL.md §Auth: "All requests (WS upgrade and REST) carry +// Authorization: Bearer … Daemon rejects with 401 (REST) or closes the WS +// (upgrade) on mismatch." + +import { agentHello, rest, sessionSnapshot, sid, WSSock } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, agentUrl, webUrl } = ctx; + + const noToken = await rest(base, null, "/api/sessions"); + r.check("REST without token → 401", noToken.status === 401, `got ${noToken.status}`); + + const badToken = await rest(base, "definitely-wrong", "/api/sessions"); + r.check("REST with bad token → 401", badToken.status === 401, `got ${badToken.status}`); + + const goodToken = await rest(base, token, "/api/sessions"); + r.check("REST with correct token → 200", goodToken.status === 200, `got ${goodToken.status}`); + + // Web WS: token arrives as ?token= query param (browsers cannot set headers). + const badWeb = new WSSock(`${webUrl}?token=wrong-token`); + const webOpened = await badWeb.opened(); + r.check("web WS with bad ?token= rejected before upgrade", !webOpened, "connection accepted"); + badWeb.close(); + + // Agent WS with bad bearer must be rejected: PROTOCOL.md §Auth requires + // closing the upgrade on mismatch (fixed: Routes() wraps /agent/ws in + // s.bearerAuth). + const badAgent = new WSSock(agentUrl, { headers: { Authorization: `Bearer wrong-token` } }); + const agentOpened = await badAgent.opened(); + let welcomed = null; + if (agentOpened) { + agentHello(badAgent, sessionSnapshot(sid("e2e-auth-bad"))); + welcomed = await badAgent.waitForFrame((f) => f.type === "welcome"); + } + r.check( + "agent WS with bad token rejected (closed before welcome)", + !agentOpened || welcomed === null, + welcomed ? "welcome received despite wrong bearer" : "", + ); + badAgent.close(); +} diff --git a/e2e/scenarios/gitlab.mjs b/e2e/scenarios/gitlab.mjs new file mode 100644 index 0000000..ca636cb --- /dev/null +++ b/e2e/scenarios/gitlab.mjs @@ -0,0 +1,60 @@ +// scenarios/gitlab.mjs — PAT connect against the fake GitLab (started by the +// driver before daemon boot; its URL is in GITLAB_BASE_URL), project mapping, +// status, and the PAT-leak guarantee. + +import { rest } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, gitlab } = ctx; + + const connect = await rest(base, token, "/api/gitlab/connect", { + method: "POST", + body: { token: gitlab.pat }, + }); + r.check( + "POST /api/gitlab/connect → 200 {username}", + connect.status === 200 && connect.json?.username === "e2e-user", + `got ${connect.status} ${connect.text}`, + ); + r.check( + "daemon validated PAT against fake /api/v4/user", + gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === gitlab.pat, + JSON.stringify(gitlab.seen.userAuths), + ); + + const status = await rest(base, token, "/api/gitlab/status"); + r.check( + "status shows connected with username + baseUrl", + status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url, + JSON.stringify(status.json), + ); + + const repos = await rest(base, token, "/api/gitlab/repos"); + r.check( + "repos mapped to protocol shape", + Array.isArray(repos.json) && repos.json.length === 2 && + repos.json.every((p) => typeof p.path === "string" && typeof p.name === "string" && typeof p.namespace === "string" && typeof p.lastActivityAt === "string" && typeof p.webUrl === "string" && typeof p.defaultBranch === "string"), + JSON.stringify(repos.json ?? null), + ); + r.check( + "repos sorted by lastActivityAt desc (daemon-side sort)", + repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" && + repos.json?.[0]?.defaultBranch === "trunk", + JSON.stringify((repos.json ?? []).map((p) => p.path)), + ); + + const leaky = [connect.text, status.text, repos.text].filter((t) => t.includes(gitlab.pat)); + r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`); + + const badConnect = await rest(base, token, "/api/gitlab/connect", { + method: "POST", + body: { token: "glpat-wrong" }, + }); + r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`); + const statusAfter = await rest(base, token, "/api/gitlab/status"); + r.check( + "failed connect does not clobber the stored PAT", + statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user", + JSON.stringify(statusAfter.json), + ); +} diff --git a/e2e/scenarios/prompt-routing.mjs b/e2e/scenarios/prompt-routing.mjs new file mode 100644 index 0000000..0a63cb7 --- /dev/null +++ b/e2e/scenarios/prompt-routing.mjs @@ -0,0 +1,94 @@ +// scenarios/prompt-routing.mjs — web-side subscribe → live event fan-out +// (including unpersisted message_update), REST prompt routed to the agent WS, +// 409 when the agent is offline. + +import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil, webClient } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, agentUrl, webUrl, state } = ctx; + const id = sid("e2e-prompt"); + const snapshot = sessionSnapshot(id, { name: "prompt-routing", repo: "e2e/harness" }); + + const browser = await webClient(webUrl, token); + const initList = await browser.waitForFrame((f) => f.type === "session_list"); + r.check("web WS sends initial session_list on connect", initList !== null); + browser.send({ type: "subscribe", sessionId: id }); + + const agent = await agentClient(agentUrl, token); + const welcome = await agentHello(agent, snapshot); + r.check("agent handshake under active web subscription", welcome?.type === "welcome"); + + // session change (hello registers the session) must reach the browser. + const listed = await browser.waitForFrame( + (f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id), + ); + r.check("session_list frame carries the new session", listed !== null); + const listedRow = listed?.sessions?.find((s) => s.id === id); + r.check( + "session_list row matches hello snapshot", + listedRow?.name === "prompt-routing" && listedRow?.repo === "e2e/harness" && listedRow?.online === true, + JSON.stringify(listedRow ?? null), + ); + + // Live fan-out: persisted + unpersisted kinds both batched to the subscriber. + agentEvent(agent, id, 1, "message_start", { message: { role: "assistant", id: "m1" } }); + agentEvent(agent, id, 2, "message_update", { delta: "live delta one " }); + agentEvent(agent, id, 3, "message_update", { delta: "live delta two" }); + agentEvent(agent, id, 4, "message_end", { + message: { role: "assistant", id: "m1", text: "live delta one live delta two", thinking: null, toolCalls: [], toolCallId: null }, + }); + + const frame = await browser.waitForFrame((f) => f.type === "events" && f.sessionId === id); + r.check("events frame received for subscribed session", frame !== null); + // Deltas flush in ≤40ms batches; wait until the seq-4 message_end fan-out lands. + const fanoutDone = await browser.waitForFrame( + (f) => f.type === "events" && f.sessionId === id && (f.events ?? []).some((e) => e.seq === 4), + ); + r.check("browser received events through message_end (seq 4)", fanoutDone !== null); + const all = browser.frames + .filter((f) => f.type === "events" && f.sessionId === id) + .flatMap((f) => f.events ?? []); + r.check( + "browser saw live message_update deltas (seq 2,3)", + all.some((e) => e.seq === 2 && e.type === "message_update" && e.delta === "live delta one ") && + all.some((e) => e.seq === 3 && e.delta === "live delta two"), + JSON.stringify(all.map((e) => `${e.seq}:${e.type}`)), + ); + const evFrames = browser.frames.filter((f) => f.type === "events" && f.sessionId === id); + r.check( + "events frame after = first seq - 1", + evFrames.every((f) => f.after === (f.events?.[0]?.seq ?? f.after + 1) - 1), + JSON.stringify(evFrames.map((f) => f.after)), + ); + + // Prompt routing: REST → agent WS. + const prompted = await rest(base, token, `/api/sessions/${id}/prompt`, { + method: "POST", + body: { message: "e2e routed prompt" }, + }); + r.check("POST prompt while online → 200 {ok:true}", prompted.status === 200 && prompted.json?.ok === true, JSON.stringify(prompted.json)); + + const promptFrame = await agent.waitForFrame((f) => f.type === "prompt"); + r.check("agent received prompt frame", promptFrame !== null); + r.check( + "prompt frame envelope + payload", + promptFrame?.v === 1 && promptFrame?.sessionId === id && promptFrame?.message === "e2e routed prompt" && typeof promptFrame?.promptId === "string", + JSON.stringify(promptFrame ?? null), + ); + + agent.close(); + const goneOffline = await waitUntil(async () => { + const res = await rest(base, token, "/api/sessions"); + return res.json?.find((s) => s.id === id)?.online === false; + }); + r.check("agent offline after close", goneOffline); + + const rejected = await rest(base, token, `/api/sessions/${id}/prompt`, { + method: "POST", + body: { message: "nobody home" }, + }); + r.check("POST prompt while offline → 409", rejected.status === 409, `got ${rejected.status}`); + + browser.close(); + state.prompt = { id }; +} diff --git a/e2e/scenarios/replay.mjs b/e2e/scenarios/replay.mjs new file mode 100644 index 0000000..30c5349 --- /dev/null +++ b/e2e/scenarios/replay.mjs @@ -0,0 +1,55 @@ +// scenarios/replay.mjs — reconnect handshake: welcome.lastSeq reflects +// persisted events; seq continuation; message_update streamed but never +// persisted. + +import { agentClient, agentEvent, agentHello, rest, sid, waitUntil } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, agentUrl, state } = ctx; + const life = state.lifecycle; // set by agent-lifecycle (seq 1..4 persisted) + const id = life.id; + + const agent = await agentClient(agentUrl, token); + const welcome = await agentHello(agent, life.snapshot); + r.check( + `reconnect welcome lastSeq=${life.lastSeq} (persisted high-water)`, + welcome?.lastSeq === life.lastSeq, + `got ${welcome?.lastSeq}, want ${life.lastSeq}`, + ); + + agentEvent(agent, id, 5, "message_update", { delta: "streaming text that must not persist" }); + agentEvent(agent, id, 6, "message_end", { + message: { role: "assistant", id: "m2", text: "assistant final", thinking: null, toolCalls: [], toolCallId: null }, + }); + agentEvent(agent, id, 7, "agent_settled", {}); + + const settled = await waitUntil( + async () => (await rest(base, token, `/api/sessions/${id}/events?after=6`)).json?.length === 1, + ); + r.check("seq 6..7 persisted (message_update skipped)", settled); + agent.close(); + + const after1 = (await rest(base, token, `/api/sessions/${id}/events?after=1`)).json ?? []; + r.check( + "events?after=1 returns only seq≥2", + JSON.stringify(after1.map((e) => e.seq)) === "[2,3,4,6,7]", + JSON.stringify(after1.map((e) => e.seq)), + ); + r.check( + "message_update (seq 5) NOT persisted", + !after1.some((e) => e.type === "message_update" || e.seq === 5), + JSON.stringify(after1.map((e) => `${e.seq}:${e.type}`)), + ); + r.check( + "REST events unaffected by skipped message_update", + after1.some((e) => e.type === "message_end" && e.message?.text === "assistant final"), + ); + + // Fresh reconnect: lastSeq must now cover everything persisted. + const agent2 = await agentClient(agentUrl, token); + const welcome2 = await agentHello(agent2, life.snapshot); + r.check("welcome lastSeq advanced to 7", welcome2?.lastSeq === 7, `got ${welcome2?.lastSeq}`); + agent2.close(); + + state.replay = { id }; +} diff --git a/e2e/scenarios/resilience.mjs b/e2e/scenarios/resilience.mjs new file mode 100644 index 0000000..01bacf7 --- /dev/null +++ b/e2e/scenarios/resilience.mjs @@ -0,0 +1,77 @@ +// scenarios/resilience.mjs — daemon crash (SIGKILL of the go process group) +// and reboot on the SAME sqlite file: every persisted event survives, sessions +// come back listed offline, and seq continues past the persisted high-water. + +import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token, agentUrl, state } = ctx; + const id = sid("e2e-res"); + const marker = `persist-me-${sid("m")}`; + const snapshot = sessionSnapshot(id); + + const agent = await agentClient(agentUrl, token); + const w1 = await agentHello(agent, snapshot); + r.check("pre-restart handshake", w1?.type === "welcome"); + agentEvent(agent, id, 1, "message_end", { + message: { role: "user", id: "mr1", text: marker, thinking: null, toolCalls: [], toolCallId: null }, + }); + agentEvent(agent, id, 2, "tool_execution_start", { toolCallId: "tc9", toolName: "bash", args: { command: "true" } }); + agentEvent(agent, id, 3, "agent_settled", {}); + const flushed = await waitUntil( + async () => (await rest(base, token, `/api/sessions/${id}/events?after=0`)).json?.length === 3, + ); + r.check("events flushed to sqlite before crash", flushed); + agent.close(); + + let restarted = true; + let restartDetail = ""; + try { + await ctx.restartDaemon(); + } catch (err) { + restarted = false; + restartDetail = String(err?.message ?? err); + } + r.check("daemon restarted on same DB after SIGKILL", restarted, restartDetail); + + const sessions = (await rest(base, token, "/api/sessions")).json ?? []; + const row = sessions.find((s) => s.id === id); + r.check( + "persisted session listed with online=false after restart", + row !== undefined && row.online === false, + JSON.stringify(row ?? null), + ); + r.check( + "pre-restart sessions from other scenarios also listed offline", + sessions.some((s) => s.id === state.lifecycle?.id && s.online === false) && + sessions.every((s) => s.online === false), + JSON.stringify(sessions.map((s) => [s.id, s.online])), + ); + + const events = (await rest(base, token, `/api/sessions/${id}/events?after=0`)).json ?? []; + r.check( + "all 3 events survive the crash", + JSON.stringify(events.map((e) => e.seq)) === "[1,2,3]", + JSON.stringify(events.map((e) => e.seq)), + ); + r.check( + "event payload intact (message text marker)", + events[0]?.message?.text === marker, + JSON.stringify(events[0] ?? null), + ); + const tail = (await rest(base, token, `/api/sessions/${id}/events?after=2`)).json ?? []; + r.check("after=2 still only seq 3 after restart", JSON.stringify(tail.map((e) => e.seq)) === "[3]"); + + // Reconnect after restart: the persisted high-water must be honoured. + const agent2 = await agentClient(agentUrl, token); + const w2 = await agentHello(agent2, snapshot); + r.check("post-restart welcome lastSeq=3", w2?.lastSeq === 3, `got ${w2?.lastSeq}`); + agentEvent(agent2, id, 4, "message_end", { + message: { role: "user", id: "mr2", text: "after restart", thinking: null, toolCalls: [], toolCallId: null }, + }); + const continued = await waitUntil( + async () => (await rest(base, token, `/api/sessions/${id}/events?after=3`)).json?.length === 1, + ); + r.check("seq continues past persisted high-water after restart", continued); + agent2.close(); +} diff --git a/e2e/scenarios/session-list.mjs b/e2e/scenarios/session-list.mjs new file mode 100644 index 0000000..5c47e67 --- /dev/null +++ b/e2e/scenarios/session-list.mjs @@ -0,0 +1,38 @@ +// scenarios/session-list.mjs — every connected web client gets session_list +// broadcasts on session state changes (agent hello / disconnect), including a +// client that subscribed after the session already existed. + +import { agentClient, agentHello, sessionSnapshot, sid, webClient } from "../lib.mjs"; + +export async function run(ctx) { + const { r, token, agentUrl, webUrl } = ctx; + const id = sid("e2e-list"); + + const first = await webClient(webUrl, token); + const initA = await first.waitForFrame((f) => f.type === "session_list"); + r.check("first web client gets initial session_list", initA !== null); + + const second = await webClient(webUrl, token); + const initB = await second.waitForFrame((f) => f.type === "session_list"); + r.check("second web client gets initial session_list", initB !== null); + + const agent = await agentClient(agentUrl, token); + await agentHello(agent, sessionSnapshot(id)); + const onA = await first.waitForFrame( + (f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === true), + ); + r.check("first client receives session_list on new session", onA !== null); + const onB = await second.waitForFrame( + (f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === true), + ); + r.check("second subscriber also receives the broadcast", onB !== null); + + agent.close(); + const offlineB = await second.waitForFrame( + (f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === false), + ); + r.check("session_list broadcast shows online=false after agent disconnect", offlineB !== null); + + first.close(); + second.close(); +} diff --git a/e2e/scenarios/spawn-validation.mjs b/e2e/scenarios/spawn-validation.mjs new file mode 100644 index 0000000..b5bbc87 --- /dev/null +++ b/e2e/scenarios/spawn-validation.mjs @@ -0,0 +1,34 @@ +// scenarios/spawn-validation.mjs — spawn request validation and status shape. +// Deliberately does NOT POST a valid repo: that path needs Docker, which the +// harness must not require. + +import { rest, TEST_TOKEN } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base, token } = ctx; + + const noSlash = await rest(base, token, "/api/spawn", { method: "POST", body: { repo: "nosuchthing" } }); + r.check( + "spawn repo without path separator → 400", + noSlash.status === 400 && typeof noSlash.json?.error === "string", + `got ${noSlash.status} ${noSlash.text}`, + ); + + const badChars = await rest(base, token, "/api/spawn", { method: "POST", body: { repo: "grp/prj!bad" } }); + r.check("spawn repo with invalid chars → 400", badChars.status === 400, `got ${badChars.status}`); + + const malformed = await fetch(`${base}/api/spawn`, { + method: "POST", + headers: { Authorization: `Bearer ${TEST_TOKEN}`, "Content-Type": "application/json" }, + body: "{not json", + }); + r.check("spawn with malformed JSON body → 400", malformed.status === 400, `got ${malformed.status}`); + await malformed.text(); + + const status = await rest(base, token, "/api/spawn/status"); + r.check( + "GET /api/spawn/status → 200 array", + status.status === 200 && Array.isArray(status.json), + `got ${status.status} ${status.text}`, + ); +} diff --git a/e2e/scenarios/web-dist.mjs b/e2e/scenarios/web-dist.mjs new file mode 100644 index 0000000..0768436 --- /dev/null +++ b/e2e/scenarios/web-dist.mjs @@ -0,0 +1,45 @@ +// scenarios/web-dist.mjs — the daemon serves the REAL built web UI from +// --webdist: index, hashed assets, manifest, SPA fallback. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { WEB_DIST } from "../lib.mjs"; + +export async function run(ctx) { + const { r, base } = ctx; + + const index = await fetch(`${base}/`); + const indexText = await index.text(); + r.check("GET / → 200 index.html", index.status === 200 && indexText.includes(""), `got ${index.status}`); + r.check( + "index references built assets", + /src="\/assets\/[^"]+\.js"/.test(indexText) && indexText.includes('id="root"'), + indexText.slice(0, 120), + ); + + const asset = fs.readdirSync(path.join(WEB_DIST, "assets")).find((f) => f.endsWith(".js")); + r.check("dist contains a hashed JS asset", Boolean(asset)); + const assetRes = await fetch(`${base}/assets/${asset}`); + const assetText = await assetRes.text(); + r.check( + "GET /assets/.js → 200 javascript", + assetRes.status === 200 && (assetRes.headers.get("content-type") ?? "").includes("javascript") && assetText.length > 1000, + `got ${assetRes.status} ${(assetRes.headers.get("content-type") ?? "")}`, + ); + + const manifest = await fetch(`${base}/manifest.webmanifest`); + const manifestJson = await manifest.json().catch(() => null); + r.check( + "GET /manifest.webmanifest → 200 with name field", + manifest.status === 200 && manifestJson?.name === "lvmh", + `got ${manifest.status}`, + ); + + const spa = await fetch(`${base}/sessions/anything/deep`); + const spaText = await spa.text(); + r.check( + "SPA fallback serves index.html for unknown routes", + spa.status === 200 && spaText.includes(""), + `got ${spa.status}`, + ); +}