Files
lvmh/e2e/lib.mjs
T

325 lines
9.8 KiB
JavaScript

// 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;
}