174 lines
7.0 KiB
JavaScript
174 lines
7.0 KiB
JavaScript
/**
|
|
* 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.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
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";
|
|
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"];
|
|
|
|
/** 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();
|
|
srv.close((error) => error ? reject(error) : resolve(port));
|
|
});
|
|
srv.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/** Bounded GET; failed/refused connections return status 0 for boot polling. */
|
|
function get(url, headers = {}) {
|
|
return new Promise((resolve) => {
|
|
const req = http.get(url, { headers }, (res) => {
|
|
res.resume();
|
|
resolve(res.statusCode ?? 0);
|
|
});
|
|
req.on("error", () => resolve(0));
|
|
req.setTimeout(5000, () => { req.destroy(); resolve(0); });
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
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");
|
|
const secret = randomBytes(32).toString("hex");
|
|
const headers = { "x-learnharness-desktop": secret };
|
|
|
|
let tail = "";
|
|
const keep = (buf) => { tail = (tail + buf.toString()).slice(-16_000); };
|
|
const dumpTail = () => {
|
|
console.error("--- server log tail " + "-".repeat(20));
|
|
for (const line of tail.replaceAll(secret, "[redacted]").split("\n").slice(-40)) {
|
|
if (line.trim()) console.error(` ${line}`);
|
|
}
|
|
};
|
|
|
|
let child;
|
|
let exited = false;
|
|
let launchError;
|
|
let failed;
|
|
try {
|
|
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) {
|
|
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})`);
|
|
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) {
|
|
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 {
|
|
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 });
|
|
}
|
|
console.log("[smoke-server] owned server stopped; disposable home removed");
|
|
}
|
|
if (failed) { dumpTail(); throw failed; }
|
|
console.log("[smoke-server] smoke passed — the packaged server boots and serves.");
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|