325 lines
9.8 KiB
JavaScript
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;
|
|
}
|