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