215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import { existsSync } from "node:fs";
|
|
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 type { FastifyInstance } from "fastify";
|
|
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 Server = {
|
|
registry: GatewayRegistry;
|
|
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with
|
|
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
|
|
mcpApp: FastifyInstance;
|
|
// Gateway origin (no path). This is what `cloud.url` expects: libcloud appends
|
|
// `/gateway` itself. Pass this to the Lua scripts.
|
|
baseUrl: string;
|
|
// Full WS endpoint (origin + `/gateway`), for direct ws clients.
|
|
socketUrl: string;
|
|
// Count captured server log lines whose `msg` contains `substring` (0 unless the
|
|
// server was started with `captureLogs: true`). Lets a test observe connection
|
|
// churn that registry.count() hides (newest-wins keeps the count pinned at 1).
|
|
countLog: (substring: string) => number;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
export type ServerOptions = {
|
|
serverPassword?: string;
|
|
opencodeUrl?: string;
|
|
requestTimeoutMs?: number;
|
|
// Capture server logs (at info level) so tests can assert on message frequency.
|
|
captureLogs?: boolean;
|
|
};
|
|
|
|
// 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 startServer(opts: ServerOptions = {}): Promise<Server> {
|
|
const logLines: string[] = [];
|
|
const logger: Config["logger"] = opts.captureLogs
|
|
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
|
|
: { level: "silent" };
|
|
|
|
const timeoutMs = opts.requestTimeoutMs ?? 5_000;
|
|
const config: Config = {
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
serverPassword: opts.serverPassword,
|
|
requestTimeoutMs: timeoutMs,
|
|
opencodeUrl: opts.opencodeUrl,
|
|
opencodeUsername: "opencode",
|
|
opencodeServerPassword: undefined,
|
|
mcpEnabled: true,
|
|
mcpHost: "127.0.0.1",
|
|
mcpPort: 0,
|
|
mcpDefaultTimeoutMs: timeoutMs,
|
|
mcpMaxTimeoutMs: 30_000,
|
|
mcpProbeTimeoutMs: timeoutMs,
|
|
logger,
|
|
};
|
|
|
|
const { app, mcpApp, registry } = await createApp(config);
|
|
await app.listen({ host: config.host, port: config.port });
|
|
// Driven in-process via inject(); ready() loads the plugin without a socket.
|
|
if (mcpApp === null) {
|
|
throw new Error("expected mcpApp to be created (mcpEnabled is true)");
|
|
}
|
|
await mcpApp.ready();
|
|
const { port } = app.server.address() as AddressInfo;
|
|
|
|
const baseUrl = `ws://127.0.0.1:${port}`;
|
|
return {
|
|
registry,
|
|
mcpApp,
|
|
baseUrl,
|
|
socketUrl: `${baseUrl}/gateway`,
|
|
countLog: (substring) => logLines.filter((line) => line.includes(substring)).length,
|
|
close: async () => {
|
|
await mcpApp.close();
|
|
await app.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
export type FakeOpencode = {
|
|
url: string;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
// 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<FakeOpencode> {
|
|
const status = opts.status ?? 200;
|
|
const server = createServer((_req, res) => {
|
|
res.writeHead(status, { "content-type": "application/json" });
|
|
res.end("{}");
|
|
});
|
|
await new Promise<void>((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<void>((resolve) => server.close(() => resolve())),
|
|
};
|
|
}
|
|
|
|
export async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
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<CraftosResult>;
|
|
abort: () => void;
|
|
// Stdout/stderr accumulated so far; lets a test await a marker line while the
|
|
// process is still running (e.g. wait for "__CLOUD_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 libcloud / 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<CraftosResult> => {
|
|
const dataDir = await mkdtemp(join(tmpdir(), "trapos-server-it-"));
|
|
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
|
|
throw new Error(
|
|
".stage/ is missing — run `just stage` (or use `just test-integration`) before the integration tests.",
|
|
);
|
|
}
|
|
const args: string[] = [
|
|
"--directory", dataDir,
|
|
"--headless",
|
|
"--mount-ro", `/staging=${LUA_DIR}`,
|
|
"--mount-ro", `/trapos=${REPO_ROOT}`,
|
|
"--mount-ro", `/apis=${join(REPO_ROOT, ".stage/apis")}`,
|
|
"--mount-ro", `/servers=${join(REPO_ROOT, ".stage/servers")}`,
|
|
"--mount-ro", `/programs=${join(REPO_ROOT, ".stage/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", (err: NodeJS.ErrnoException) => {
|
|
if (err.code !== "ABORT_ERR") {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|