mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-09 10:39:36 -10:00
291 lines
13 KiB
TypeScript
Executable File
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)");
|