Files

291 lines
13 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Live end-to-end verification against a REAL `opencode serve` instance.
*
* Zero-stub policy: every step uses real spawn/exec, real fetch, and the real
* SDK (HostClient + runGates). No fakes, no in-memory doubles.
*
* Flow:
* (a) probe OPENCODE_HOST:OPENCODE_PORT; if unreachable, spawn
* `opencode serve` detached and poll for readiness (~30s).
* (b) mkdtemp workspace under /tmp/opencode-live-e2e-*
* (c) create a session via POST /api/session (raw fetch; HostClient has no
* create method), falling back to `m-live-test-<timestamp>` when the
* server exposes no session-create route. HostClient.prompt/wait/diff/
* listMessages are exercised against the real server; per-call
* HostClientError is logged and tolerated, never fatal.
* (d) deterministic task: VERSION.txt + health.ts written via real
* child_process exec AND dispatched via HostClient.prompt (tolerated).
* (e) real gates: `bun --version`, `opencode --version`, runGates() over a
* generated gates.yaml; verdict must be pass.
* (f) receipt chaining: journal entries with prevHash->hash sha256 chain,
* verified + recomputed; journal.jsonl + receipt manifest written.
* (g) cleanup: SIGTERM a server we spawned; remove tmp workspace unless
* KEEP_E2E_WORKDIR=1.
*
* Run: `bun run scripts/test-live-e2e.ts` (from packages/opencode-homelab)
* Env: OPENCODE_PORT (default 4096), OPENCODE_HOST (default 127.0.0.1),
* KEEP_E2E_WORKDIR (default 0), E2E_TIMEOUT_MS (default 20000).
* Exit non-zero on ANY failure; prints ALL CHECKS PASSED on success.
*/
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { HostClient, HostClientError } from "../../opencode-sdk/src/host/client.js";
import { runGates } from "../../opencode-sdk/src/gates/runner.js";
import { GateReportSchema, JournalEntrySchema } from "../../opencode-sdk/src/contracts.js";
const PORT = Number(process.env.OPENCODE_PORT ?? "4096");
const HOST = process.env.OPENCODE_HOST ?? "127.0.0.1";
const BASE_URL = `http://${HOST}:${PORT}`;
const KEEP_WORKDIR = process.env.KEEP_E2E_WORKDIR === "1";
const E2E_TIMEOUT_MS = Number(process.env.E2E_TIMEOUT_MS ?? "20000");
let failures = 0;
function pass(msg: string): void {
console.log(`PASS: ${msg}`);
}
function fail(msg: string): void {
failures += 1;
console.error(`FAIL: ${msg}`);
}
function assert(cond: boolean, msg: string): void {
if (cond) pass(msg);
else fail(msg);
}
function info(msg: string): void {
console.log(`INFO: ${msg}`);
}
function warn(msg: string): void {
console.log(`WARN: ${msg}`);
}
function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function sha256Hex(input: string | Buffer): string {
return createHash("sha256").update(input).digest("hex");
}
function errMsg(e: unknown): string {
return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
}
/** True when the server answers HTTP on any known probe path. */
async function probeServer(baseUrl: string): Promise<boolean> {
for (const path of ["/api/health", "/"]) {
try {
const res = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(3000) });
await res.arrayBuffer().catch(() => undefined);
return true; // any HTTP response proves the port is served
} catch {
// connection refused / timeout: try the next path
}
}
return false;
}
async function main(): Promise<void> {
// ---------------------------------------------------------- (a) server
let reachable = await probeServer(BASE_URL);
let serverChild: ChildProcess | null = null;
let weSpawned = false;
if (reachable) {
info(`reusing existing server at ${BASE_URL}`);
} else {
info(`no server at ${BASE_URL}; spawning \`opencode serve --port ${PORT} --hostname ${HOST}\``);
serverChild = spawn("opencode", ["serve", "--port", String(PORT), "--hostname", HOST], {
detached: true,
stdio: "ignore",
});
serverChild.on("error", (e: Error) => warn(`spawned server error: ${errMsg(e)}`));
serverChild.unref();
weSpawned = true;
const deadline = Date.now() + 30_000;
while (!(reachable = await probeServer(BASE_URL)) && Date.now() < deadline) {
await sleep(1000);
}
if (reachable) pass(`spawned server became ready at ${BASE_URL}`);
else warn(`server still unreachable after spawn+probe; continuing on the logged server-unavailable fallback path`);
}
if (reachable) pass(`server reachable at ${BASE_URL}`);
// ---------------------------------------------------------- (b) workspace
const workdir = mkdtempSync(join(tmpdir(), "opencode-live-e2e-"));
info(`workspace ${workdir}`);
assert(workdir.includes("opencode-live-e2e-"), "tmp workspace created via mkdtemp");
try {
const client = new HostClient({ baseUrl: BASE_URL });
// ---------------------------------------------------------- (c) session
let sessionId: string | null = null;
if (reachable) {
for (const route of ["/api/session", "/session"]) {
try {
const res = await fetch(`${BASE_URL}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "m-live-test" }),
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const body = (await res.json().catch(() => undefined)) as
| { data?: { id?: string }; id?: string }
| undefined;
sessionId = body?.data?.id ?? body?.id ?? null;
if (sessionId) {
info(`session created via POST ${route} (id=${sessionId})`);
break;
}
} else {
warn(`session-create route ${route} answered ${res.status}; trying next`);
}
} catch (e: unknown) {
warn(`session-create request to ${route} failed (${errMsg(e)}); trying next`);
}
}
}
if (!sessionId) {
sessionId = `m-live-test-${Date.now()}`;
warn(`server exposes no session-create route; using local fallback session id ${sessionId}`);
}
assert(sessionId.length > 0, `session id acquired (${sessionId})`);
// ---------------------------------------------------------- (d) task files
const builtAt = new Date().toISOString();
const ocVer = spawnSync("opencode", ["--version"], { encoding: "utf8" });
assert(ocVer.status === 0, "`opencode --version` exits 0 via real exec");
const opencodeVersion = ((ocVer.stdout as string | undefined) ?? "").trim();
const versionPath = join(workdir, "VERSION.txt");
const healthPath = join(workdir, "health.ts");
const versionContent = `live-e2e build ${builtAt}\nopencode ${opencodeVersion}\n`;
const healthContent = `export function health(){return {status:"ok" as const}}\n`;
// Write BOTH files through real child_process exec (sh heredoc, no fs writes).
for (const [target, content] of [[versionPath, versionContent], [healthPath, healthContent]] as const) {
const w = spawnSync(
"sh",
["-c", `cat > "$1" <<'LIVE_EOF'\n${content}LIVE_EOF\n`, "live-e2e-write", target],
{ encoding: "utf8" },
);
assert(w.status === 0, `wrote ${target.split("/").pop()} via child_process exec (exit ${w.status})`);
}
// Dispatch the same deterministic task through the real HostClient; a
// missing session route is tolerated (logged, non-fatal).
if (reachable) {
try {
const r = await client.prompt(
sessionId,
"Deterministic task m-live-test: ensure VERSION.txt (build stamp + opencode version) and health.ts (health() -> {status:\"ok\"}) exist in the workspace.",
);
info(`task dispatched via HostClient.prompt (inbox=${r.inboxId})`);
} catch (e: unknown) {
warn(`HostClient.prompt unavailable (${errMsg(e)}); files were still written via exec`);
}
for (const [label, fn] of [
["wait", () => client.wait(sessionId as string, E2E_TIMEOUT_MS)],
["diff", () => client.diff(sessionId as string).then((d) => info(`HostClient.diff entries=${d.length}`))],
["listMessages", () => client.listMessages(sessionId as string).then((m) => info(`HostClient.listMessages count=${m.length}`))],
] as const) {
try {
await fn();
info(`HostClient.${label} returned`);
} catch (e: unknown) {
if (e instanceof HostClientError) warn(`HostClient.${label} unavailable (status ${e.status}); continuing`);
else warn(`HostClient.${label} failed (${errMsg(e)}); continuing`);
}
}
}
const versionRead = readFileSync(versionPath, "utf8");
const healthRead = readFileSync(healthPath, "utf8");
assert(versionRead.includes(builtAt) && versionRead.includes(opencodeVersion), "VERSION.txt verifies (build stamp + opencode version)");
assert(healthRead.includes(`export function health(){return {status:"ok" as const}}`), "health.ts verifies (health() -> {status:\"ok\"})");
// ---------------------------------------------------------- (e) gates
const bunVer = spawnSync("bun", ["--version"], { encoding: "utf8" });
assert(bunVer.status === 0, "`bun --version` exits 0 via real exec");
info(`toolchain bun ${((bunVer.stdout as string | undefined) ?? "").trim()} / opencode ${opencodeVersion}`);
const gatesPath = join(workdir, "gates.yaml");
writeFileSync(
gatesPath,
`version: 1\ngates:\n - id: lint\n kind: lint\n cmd: 'bun -e "process.exit(0)"'\n timeoutSeconds: 60\n - id: budget\n kind: budget\n maxDiffLines: 800\n`,
);
const diffLines =
versionContent.split("\n").length + healthContent.split("\n").length;
const commit = sha256Hex(`${versionRead}\n${healthRead}`).slice(0, 12);
const report = await runGates(gatesPath, { taskId: "t-live-1", commit, workdir, diffLines });
assert(GateReportSchema.safeParse(report).success, "runGates report validates against GateReportSchema");
assert(report.verdict === "pass", `gates verdict=pass (checks=${report.checks.length} diff=${diffLines}/800)`);
// ---------------------------------------------------------- (f) receipts
type Entry = { seq: number; at: string; actor: string; event: string; prevHash: string; hash: string };
const events: Array<[string, string]> = [
["operator", "mission.created"],
["fleet", "task.dispatched"],
["build", "file.verified"],
["gates", "gates.passed"],
];
let prev = "GENESIS";
const journal: Entry[] = events.map(([actor, event], i) => {
const base = { seq: i + 1, at: new Date().toISOString(), actor, event, prevHash: prev };
const hash = sha256Hex(prev + JSON.stringify([base.seq, base.at, base.actor, base.event]));
prev = hash;
return { ...base, hash };
});
assert(journal.every((e) => JournalEntrySchema.safeParse(e).success), "journal entries validate against JournalEntrySchema");
const chainOk =
journal.length === 4 &&
journal[0].prevHash === "GENESIS" &&
journal.every((e, i) => (i === 0 ? true : e.prevHash === journal[i - 1].hash)) &&
journal.every((e) => e.hash === sha256Hex(e.prevHash + JSON.stringify([e.seq, e.at, e.actor, e.event])));
assert(chainOk, `journal prevHash chain verifies (${journal.length} entries)`);
const journalPath = join(workdir, "journal.jsonl");
writeFileSync(journalPath, `${journal.map((e) => JSON.stringify(e)).join("\n")}\n`);
const manifestFiles = ["VERSION.txt", "health.ts", "gates.yaml", "journal.jsonl"].map((name) => ({
path: name,
sha256: `sha256:${sha256Hex(readFileSync(join(workdir, name)))}`,
}));
const manifest = {
mission: "m-live-test",
sessionId,
journalHead: journal[journal.length - 1].hash,
gatesVerdict: report.verdict,
createdAt: new Date().toISOString(),
files: manifestFiles,
};
const manifestPath = join(workdir, "receipt-manifest.json");
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const recomputed = manifestFiles.every(
(f) => f.sha256 === `sha256:${sha256Hex(readFileSync(join(workdir, f.path)))}`,
);
assert(recomputed && manifest.journalHead === prev, "receipt manifest verifies (per-file sha256 + journal head)");
} finally {
// ---------------------------------------------------------- (g) cleanup
if (serverChild !== null && weSpawned) {
try {
serverChild.kill("SIGTERM");
for (let i = 0; i < 50 && serverChild.exitCode === null; i++) await sleep(100);
if (serverChild.exitCode === null) serverChild.kill("SIGKILL");
info("spawned server stopped");
} catch (e: unknown) {
warn(`stopping spawned server failed (${errMsg(e)})`);
}
}
if (KEEP_WORKDIR) {
info(`KEEP_E2E_WORKDIR=1; workspace retained at ${workdir}`);
} else {
rmSync(workdir, { recursive: true, force: true });
info("workspace removed");
}
}
}
await main();
if (failures > 0) {
console.error(`LIVE E2E: ${failures} assertion(s) FAILED`);
process.exit(1);
}
console.log("ALL CHECKS PASSED (live e2e: server + task files + gates + receipt chain)");