mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-08 17:18:16 -10:00
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
/**
|
|
* Live host integration suite: proves real HTTP integration with OpenCode v2.
|
|
*
|
|
* Spawns a real, temporary `opencode serve --port 4199` process, connects via
|
|
* {@link HostAdapter} (global fetch — NEVER a mock), exercises the session
|
|
* lifecycle (create, prompt, idle-wait barrier, diff, messages, delete), and
|
|
* shuts the server down cleanly.
|
|
*
|
|
* Environment overrides:
|
|
* - `OPENCODE_BIN`: path to the opencode binary (default: `opencode` on PATH).
|
|
* - `OPENCODE_TEST_PORT`: port for the temp server (default: 4199).
|
|
*
|
|
* If the binary is not available the suite skips gracefully with a clear
|
|
* message instead of failing; every skip path is logged, never silent.
|
|
*/
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
import { HostAdapter } from "../src/host/client.js";
|
|
|
|
const PORT = Number(process.env["OPENCODE_TEST_PORT"] ?? 4199);
|
|
const PASSWORD = "test-password-123";
|
|
const BIN = process.env["OPENCODE_BIN"] ?? "opencode";
|
|
|
|
const adapter = new HostAdapter({
|
|
baseUrl: `http://127.0.0.1:${PORT}`,
|
|
password: PASSWORD,
|
|
});
|
|
|
|
let serverProc: ChildProcess | null = null;
|
|
let skipReason: string | null = null;
|
|
|
|
function skipOrLive(): boolean {
|
|
if (skipReason !== null) {
|
|
console.warn(`[host-live] SKIP: ${skipReason}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function pollInfo(timeoutMs: number): Promise<{ version?: string }> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastErr: unknown = null;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const h = await adapter.health();
|
|
if (h.ok) return h;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
throw new Error(
|
|
`live server on :${PORT} never became ready: ${(lastErr as Error)?.message ?? lastErr}`,
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const workdir = mkdtempSync(join(tmpdir(), "heretek-host-live-"));
|
|
try {
|
|
serverProc = spawn(BIN, ["serve", "--port", String(PORT), "--hostname", "127.0.0.1"], {
|
|
cwd: workdir,
|
|
env: { ...process.env, OPENCODE_PASSWORD: PASSWORD },
|
|
stdio: "ignore",
|
|
});
|
|
} catch (err) {
|
|
skipReason = `could not spawn "${BIN}": ${(err as Error).message}. Install OpenCode v2 or set OPENCODE_BIN.`;
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => {
|
|
if (serverProc === null) return resolve();
|
|
serverProc.once("error", (err: Error) => {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
skipReason =
|
|
`binary "${BIN}" not found on PATH. Install OpenCode v2 (opencode.ai) or set OPENCODE_BIN to skip gracefully.`;
|
|
} else {
|
|
skipReason = `server process error: ${err.message}`;
|
|
}
|
|
resolve();
|
|
});
|
|
serverProc.once("spawn", () => resolve());
|
|
});
|
|
if (skipReason !== null || serverProc === null) return;
|
|
// The process may exit immediately (bad flags, port in use) — surface that
|
|
// as a hard failure only when the binary exists; missing binary skips.
|
|
const exited = await new Promise<number | null>((resolve) => {
|
|
if (serverProc === null) return resolve(null);
|
|
let done = false;
|
|
serverProc.once("exit", (code) => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve(code);
|
|
}
|
|
});
|
|
setTimeout(() => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve(null);
|
|
}
|
|
}, 500);
|
|
});
|
|
if (exited !== null) {
|
|
skipReason = null;
|
|
throw new Error(`"${BIN} serve" exited immediately with code ${exited}`);
|
|
}
|
|
await pollInfo(30_000);
|
|
}, 45_000);
|
|
|
|
afterAll(async () => {
|
|
if (serverProc !== null) {
|
|
const proc = serverProc;
|
|
serverProc = null;
|
|
proc.kill("SIGTERM");
|
|
await new Promise<void>((resolve) => {
|
|
let done = false;
|
|
proc.once("exit", () => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve();
|
|
}
|
|
});
|
|
setTimeout(() => {
|
|
if (!done) {
|
|
done = true;
|
|
try {
|
|
proc.kill("SIGKILL");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
resolve();
|
|
}
|
|
}, 3000);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("live host /api/info reports ok with a v2 version", async () => {
|
|
if (skipOrLive()) return;
|
|
const h = await adapter.health();
|
|
expect(h.ok).toBe(true);
|
|
expect(typeof h.version).toBe("string");
|
|
expect(h.version as string).toMatch(/^2\./);
|
|
}, 30_000);
|
|
|
|
test("live host session lifecycle: create, prompt, wait, diff, messages, delete", async () => {
|
|
if (skipOrLive()) return;
|
|
const created = await adapter.createSession();
|
|
expect(created.id.length).toBeGreaterThan(0);
|
|
const sessionId = created.id;
|
|
|
|
// Prompt admission is async: the endpoint returns the admitted user inbox
|
|
// item, NOT the assistant answer.
|
|
const marker = `host-live-probe-${Date.now()}`;
|
|
const admitted = await adapter.prompt(sessionId, `Reply with exactly: live-test-ok. Do not use any tools. (${marker})`);
|
|
expect(admitted.inboxId.length).toBeGreaterThan(0);
|
|
|
|
// Race-free idle barrier: resolves 204 once the agent loop drains.
|
|
await adapter.wait(sessionId, 90_000);
|
|
|
|
// Diff shape is host-defined; assert the envelope contract only.
|
|
const diffs = await adapter.diff(sessionId);
|
|
expect(Array.isArray(diffs)).toBe(true);
|
|
|
|
// The admitted user message must be retrievable verbatim.
|
|
const messages = await adapter.listMessages(sessionId);
|
|
expect(messages.length).toBeGreaterThan(0);
|
|
const seen = messages.some((m) => JSON.stringify(m).includes(marker));
|
|
expect(seen).toBe(true);
|
|
|
|
await adapter.deleteSession(sessionId);
|
|
}, 120_000);
|