import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { AddressInfo } from "node:net"; import { createApp, type Config } from "../src/app.js"; import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const LUA_DIR = join(HERE, "lua"); const REPO_ROOT = join(HERE, "../../.."); export type Sandbox = { registry: GatewayRegistry; // Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends // `/gateway` itself. Pass this to the Lua scripts. baseUrl: string; // Full WS endpoint (origin + `/gateway`), for direct ws clients. socketUrl: string; close: () => Promise; }; export type SandboxOptions = { sandboxPassword?: string; opencodeUrl?: string; requestTimeoutMs?: number; }; // Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a // silent logger and test-supplied config (never reads process.env). export async function startSandbox(opts: SandboxOptions = {}): Promise { const config: Config = { host: "127.0.0.1", port: 0, sandboxPassword: opts.sandboxPassword, requestTimeoutMs: opts.requestTimeoutMs ?? 5_000, opencodeUrl: opts.opencodeUrl, opencodeUsername: "opencode", opencodeServerPassword: undefined, logger: { level: "silent" }, }; const { app, registry } = await createApp(config); await app.listen({ host: config.host, port: config.port }); const { port } = app.server.address() as AddressInfo; const baseUrl = `ws://127.0.0.1:${port}`; return { registry, baseUrl, socketUrl: `${baseUrl}/gateway`, close: () => app.close(), }; } export type FakeOpencode = { url: string; close: () => Promise; }; // Tiny stand-in for the opencode server. The probe issues `GET /config`; we reply // with `status` (default 200) on every path so opencodeOk can be steered per test. export async function startFakeOpencode(opts: { status?: number } = {}): Promise { const status = opts.status ?? 200; const server = createServer((_req, res) => { res.writeHead(status, { "content-type": "application/json" }); res.end("{}"); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, close: () => new Promise((resolve) => server.close(() => resolve())), }; } export async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) { return; } await sleep(50); } throw new Error(`waitFor timed out: ${label}`); } export type CraftosResult = { status: number | null; signal: NodeJS.Signals | null; output: string; }; export type CraftosHandle = { done: Promise; abort: () => void; // Stdout/stderr accumulated so far; lets a test await a marker line while the // process is still running (e.g. wait for "__SANDBOX_READY__" before acting). snapshot: () => string; }; // Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the // repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can // require the real libsandbox / servers / programs. export function startCraftos( luaName: string, opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {}, ): CraftosHandle { const timeoutMs = opts.timeoutMs ?? 15_000; const controller = new AbortController(); const chunks: Buffer[] = []; const done = (async (): Promise => { const dataDir = await mkdtemp(join(tmpdir(), "trap-sandbox-it-")); const watchdog = setTimeout(() => controller.abort(), timeoutMs); try { const args: string[] = [ "--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`, "--mount-ro", `/trapos=${REPO_ROOT}`, "--mount-ro", `/apis=${join(REPO_ROOT, "apis")}`, "--mount-ro", `/servers=${join(REPO_ROOT, "servers")}`, "--mount-ro", `/programs=${join(REPO_ROOT, "programs")}`, ]; if (opts.computerId !== undefined) { args.push("--id", String(opts.computerId)); } if (process.platform === "darwin") { args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources"); } args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel)); const child = spawn("craftos", args, { signal: controller.signal }); child.stdout.on("data", (d: Buffer) => chunks.push(d)); child.stderr.on("data", (d: Buffer) => chunks.push(d)); const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("close", (code, signal) => resolve({ status: code, signal })); child.once("error", () => resolve({ status: null, signal: null })); }); return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") }; } finally { clearTimeout(watchdog); await rm(dataDir, { recursive: true, force: true }); } })(); return { done, abort: () => controller.abort(), snapshot: () => Buffer.concat(chunks).toString("utf8") }; } export function formatFailure(message: string, craftosOutput: string): string { return ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"].join("\n"); } function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string { const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`; const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)]; const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `; return `${setup}shell.run(${parts.join(", ")})`; } function luaQuote(value: string): string { return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }