Files
learnharness/scripts/smoke-server.mjs
T

174 lines
7.0 KiB
JavaScript
Raw Normal View History

/**
2026-11-01 01:13:43 +03:00
* Boot a standalone payload, then probe its GET-only desktop API surface.
* node scripts/smoke-server.mjs [--standalone <directory>] [--runtime <Node/Electron>] [--electron]
* Defaults to .next/standalone and host Node; --electron retains its legacy meaning.
* Uses an isolated empty home/data/auth environment and a per-launch API capability.
* No real keys, provider generation, installed app UI or production data are used.
*/
2026-11-01 01:13:43 +03:00
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
2026-11-01 01:13:43 +03:00
import { randomBytes } from "node:crypto";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs";
import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
2026-11-01 01:13:43 +03:00
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(import.meta.dirname, "..");
const POLL_MS = 500;
const BOOT_TIMEOUT_MS = 30_000;
const PROBES = ["/api/graph", "/api/models", "/api/settings"];
2026-11-01 01:13:43 +03:00
/** Match the provider smoke's checkout default and caller-relative explicit paths. */
export function resolveSmokeOptions(args = []) {
let standalone = path.join(ROOT, ".next", "standalone");
let runtime;
let electron = false;
for (let i = 0; i < args.length; i++) {
const option = args[i];
if (option === "--electron") { electron = true; continue; }
assert.ok(option === "--standalone" || option === "--runtime", `Unknown option: ${option}`);
const value = args[++i];
assert.ok(value && !value.startsWith("--"), `${option} requires a path`);
if (option === "--standalone") standalone = path.resolve(value);
else runtime = path.resolve(value);
}
assert.ok(!(electron && runtime), "Use either --electron or --runtime, not both");
return {
standalone,
server: path.join(standalone, "server.js"),
runtime: runtime ?? (electron ? createRequire(import.meta.url)("electron") : process.execPath),
};
}
export function validateSmokeOptions({ standalone, server, runtime }) {
assert.ok(fs.existsSync(standalone) && fs.statSync(standalone).isDirectory(), `Standalone directory not found: ${standalone}`);
assert.ok(fs.existsSync(server) && fs.statSync(server).isFile(), `Standalone server not found: ${server} — run next build + node scripts/build-server.mjs first`);
assert.ok(fs.existsSync(runtime) && fs.statSync(runtime).isFile(), `Runtime not found: ${runtime}`);
fs.accessSync(runtime, fs.constants.X_OK);
for (const rel of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) {
assert.ok(!fs.existsSync(path.join(standalone, rel)), `Credential/data path leaked into standalone: ${rel}`);
}
}
function freePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address();
2026-11-01 01:13:43 +03:00
srv.close((error) => error ? reject(error) : resolve(port));
});
srv.on("error", reject);
});
}
2026-11-01 01:13:43 +03:00
/** Bounded GET; failed/refused connections return status 0 for boot polling. */
function get(url, headers = {}) {
return new Promise((resolve) => {
2026-11-01 01:13:43 +03:00
const req = http.get(url, { headers }, (res) => {
res.resume();
resolve(res.statusCode ?? 0);
});
req.on("error", () => resolve(0));
2026-11-01 01:13:43 +03:00
req.setTimeout(5000, () => { req.destroy(); resolve(0); });
});
}
async function main() {
2026-11-01 01:13:43 +03:00
const { standalone, server, runtime } = resolveSmokeOptions(process.argv.slice(2));
validateSmokeOptions({ standalone, server, runtime });
console.log(`[smoke-server] standalone: ${standalone}; runtime: ${runtime}`);
// All nested gates use the SAME payload and runtime, never staging fallbacks.
verifyDemoRuntime(standalone, runtime);
verifyProviderRuntime(standalone, runtime);
verifyCompressionRuntime(standalone, runtime);
const port = await freePort();
const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
const dataDir = path.join(home, "data");
const authFile = path.join(home, "auth.json");
2026-11-01 01:13:43 +03:00
const secret = randomBytes(32).toString("hex");
const headers = { "x-learnharness-desktop": secret };
2026-11-01 01:13:43 +03:00
let tail = "";
const keep = (buf) => { tail = (tail + buf.toString()).slice(-16_000); };
const dumpTail = () => {
console.error("--- server log tail " + "-".repeat(20));
2026-11-01 01:13:43 +03:00
for (const line of tail.replaceAll(secret, "[redacted]").split("\n").slice(-40)) {
if (line.trim()) console.error(` ${line}`);
}
};
2026-11-01 01:13:43 +03:00
let child;
let exited = false;
2026-11-01 01:13:43 +03:00
let launchError;
let failed;
try {
2026-11-01 01:13:43 +03:00
fs.writeFileSync(authFile, "{}\n", { mode: 0o600 });
child = spawn(runtime, [server], {
cwd: standalone,
env: {
...probeEnv(process.env, home),
ELECTRON_RUN_AS_NODE: "1",
NODE_ENV: "production", PORT: String(port), HOSTNAME: "127.0.0.1",
LH_DATA_DIR: dataDir, LH_AUTH_FILE: authFile,
LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret,
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", keep);
child.stderr.on("data", keep);
child.on("exit", () => { exited = true; });
child.on("error", (error) => { launchError = error; });
let boot = 0;
const deadline = Date.now() + BOOT_TIMEOUT_MS;
while (Date.now() < deadline) {
2026-11-01 01:13:43 +03:00
if (launchError) throw launchError;
if (exited) throw new Error(`server exited before answering on ${url} (code ${child.exitCode}, signal ${child.signalCode})`);
boot = await get(url);
if (boot === 200) break;
await new Promise((r) => setTimeout(r, POLL_MS));
}
if (boot !== 200) throw new Error(`server never answered 200 on ${url} (last status: ${boot})`);
console.log(`[smoke-server] boot ok: GET / → 200 (${url})`);
2026-11-01 01:13:43 +03:00
assert.equal(await get(url + "/api/models"), 403, "Desktop API must deny unauthenticated requests");
console.log("[smoke-server] unauthenticated GET /api/models → 403");
for (const route of PROBES) {
2026-11-01 01:13:43 +03:00
const status = await get(url + route, headers);
assert.equal(status, 200, `Authenticated GET ${route} → ${status} (expected 200)`);
console.log(`[smoke-server] authenticated GET ${route} → 200`);
}
} catch (err) {
failed = err;
} finally {
try {
2026-11-01 01:13:43 +03:00
if (child?.pid && !exited) {
await new Promise((resolve, reject) => {
const force = setTimeout(() => child.kill("SIGKILL"), 3000);
const deadline = setTimeout(() => reject(new Error(`Server cleanup timed out (pid ${child.pid})`)), 5000);
child.once("exit", () => { clearTimeout(force); clearTimeout(deadline); resolve(); });
child.kill("SIGTERM");
});
}
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
2026-11-01 01:13:43 +03:00
console.log("[smoke-server] owned server stopped; disposable home removed");
}
2026-11-01 01:13:43 +03:00
if (failed) { dumpTail(); throw failed; }
console.log("[smoke-server] smoke passed — the packaged server boots and serves.");
}
2026-11-01 01:13:43 +03:00
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(`[smoke-server] FAIL: ${err.stack || err}`);
process.exitCode = 1;
});
}