e2e: integration harness (69/69 green) + fix /agent/ws missing bearer auth (BUG-1)
This commit is contained in:
@@ -4,3 +4,4 @@ dist
|
||||
*.db
|
||||
.pi/
|
||||
daemon/.pi-scratch/
|
||||
daemon/lvmh-daemon
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
Binary file not shown.
@@ -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
@@ -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());
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user