e2e: integration harness (69/69 green) + fix /agent/ws missing bearer auth (BUG-1)

This commit is contained in:
Raphael Westphal
2026-08-18 14:13:15 +02:00
parent 6ba718730e
commit ecb91e941c
17 changed files with 1129 additions and 2 deletions
+58
View File
@@ -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.
+149
View File
@@ -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());
+66
View File
@@ -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();
},
});
});
});
}
+324
View File
@@ -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;
}
+78
View File
@@ -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 };
}
+42
View File
@@ -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();
}
+60
View File
@@ -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),
);
}
+94
View File
@@ -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 };
}
+55
View File
@@ -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 };
}
+77
View File
@@ -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();
}
+38
View File
@@ -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();
}
+34
View File
@@ -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}`,
);
}
+45
View File
@@ -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("<!doctype html>"), `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/<bundle>.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("<!doctype html>"),
`got ${spa.status}`,
);
}