chore: auto-commit after stop
GSD-Unit: M001
This commit is contained in:
+101
-122
@@ -1,30 +1,13 @@
|
||||
/**
|
||||
* smoke-server.mjs — boot the packaged standalone server and prove it serves.
|
||||
*
|
||||
* The payload gate that pays for itself (2026-10-08): `next build` success
|
||||
* and a completed electron-builder run say NOTHING about whether the
|
||||
* packaged server actually starts. On its first day this smoke caught two
|
||||
* real defects nothing else saw: (1) a trace-exclude regression that
|
||||
* stripped the pi engine packages to bare package.jsons — the server
|
||||
* answered 500 on every route with ERR_MODULE_NOT_FOUND; (2) before that,
|
||||
* the dev auth.json (BYOK keys) shipping inside the bundle. It runs
|
||||
* between build-server.mjs and electron-builder so a broken payload fails
|
||||
* in seconds, not after a full packaging pass.
|
||||
*
|
||||
* What it does: spawns .next/standalone/server.js exactly the way the
|
||||
* Electron shell does (electron/main.cjs) — with --electron it uses
|
||||
* Electron's bundled Node via ELECTRON_RUN_AS_NODE, otherwise host Node —
|
||||
* 127.0.0.1 on a kernel-assigned
|
||||
* free port, NODE_ENV=production, a THROWAWAY LH_DATA_DIR and an EMPTY
|
||||
* LH_AUTH_FILE under os.tmpdir() (no real keys or data ever touch it) —
|
||||
* polls GET / until 200, then probes the GET-only API surface (graph /
|
||||
* models / settings — no side effects). Any non-200, an early server
|
||||
* exit, or a 30s boot timeout fails the gate and prints the server's own
|
||||
* log tail for diagnosis. The child is ALWAYS killed before exit — a
|
||||
* leaked server holds its port against the next run (learned the hard
|
||||
* way, same day).
|
||||
* 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";
|
||||
@@ -34,161 +17,157 @@ 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 STANDALONE = path.join(ROOT, ".next", "standalone");
|
||||
const SERVER = path.join(STANDALONE, "server.js");
|
||||
const ELECTRON_MODE = process.argv.includes("--electron");
|
||||
const RUNTIME = ELECTRON_MODE ? createRequire(import.meta.url)("electron") : process.execPath;
|
||||
|
||||
const POLL_MS = 500;
|
||||
const BOOT_TIMEOUT_MS = 30_000;
|
||||
/** GET-only probes — the routes that exercised today's failure class. */
|
||||
const PROBES = ["/api/graph", "/api/models", "/api/settings"];
|
||||
|
||||
if (!fs.existsSync(SERVER)) {
|
||||
console.error("[smoke-server] .next/standalone/server.js missing — run next build first.");
|
||||
process.exit(1);
|
||||
/** 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A free TCP port (listen(0) → the kernel assigns one) — mirrors main.cjs. */
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
srv.close(() => resolve(port));
|
||||
srv.close((error) => error ? reject(error) : resolve(port));
|
||||
});
|
||||
srv.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** One GET; resolves the status code (0 = connection refused/failed). */
|
||||
function get(url) {
|
||||
/** Bounded GET; failed/refused connections return status 0 for boot polling. */
|
||||
function get(url, headers = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(url, (res) => {
|
||||
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);
|
||||
});
|
||||
req.setTimeout(5000, () => { req.destroy(); resolve(0); });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// HTTP boot alone never imports the dynamically loaded demo engine.
|
||||
// Fail before packaging if its standalone dependency graph is incomplete.
|
||||
verifyDemoRuntime(STANDALONE, RUNTIME);
|
||||
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
|
||||
verifyProviderRuntime(STANDALONE, RUNTIME);
|
||||
// A plain GET without Accept-Encoding never exercises gzip backpressure.
|
||||
verifyCompressionRuntime(STANDALONE, RUNTIME);
|
||||
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");
|
||||
fs.writeFileSync(authFile, "{}\n", "utf-8");
|
||||
const secret = randomBytes(32).toString("hex");
|
||||
const headers = { "x-learnharness-desktop": secret };
|
||||
|
||||
const tail = []; // rolling log tail for failure diagnosis
|
||||
let tailBytes = 0;
|
||||
const keep = (buf) => {
|
||||
for (const line of buf.toString().split("\n")) {
|
||||
tail.push(line);
|
||||
tailBytes += line.length + 1;
|
||||
}
|
||||
while (tailBytes > 16_000 && tail.length > 2) {
|
||||
tailBytes -= tail[0].length + 1;
|
||||
tail.shift();
|
||||
}
|
||||
};
|
||||
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.slice(-40)) if (line.trim()) console.error(` ${line}`);
|
||||
for (const line of tail.replaceAll(secret, "[redacted]").split("\n").slice(-40)) {
|
||||
if (line.trim()) console.error(` ${line}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[smoke-server] runtime: ${ELECTRON_MODE ? "Electron bundled Node" : "host Node"}`);
|
||||
const child = spawn(RUNTIME, [SERVER], {
|
||||
cwd: STANDALONE,
|
||||
env: {
|
||||
...probeEnv(process.env, home),
|
||||
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
LH_DATA_DIR: dataDir,
|
||||
LH_AUTH_FILE: authFile,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let child;
|
||||
let exited = false;
|
||||
child.stdout.on("data", keep);
|
||||
child.stderr.on("data", keep);
|
||||
child.on("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const kill = () =>
|
||||
new Promise((resolve) => {
|
||||
if (exited) return resolve();
|
||||
const done = () => resolve();
|
||||
child.once("exit", done);
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!exited) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
done();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
let failed = null;
|
||||
let launchError;
|
||||
let failed;
|
||||
try {
|
||||
// 1) Boot: poll GET / until 200 (or timeout / early exit).
|
||||
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 (exited) throw new Error(`server exited before answering on ${url}`);
|
||||
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})`);
|
||||
|
||||
// 2) API surface: every probe must be 200.
|
||||
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);
|
||||
if (status !== 200) throw new Error(`GET ${route} → ${status} (expected 200)`);
|
||||
console.log(`[smoke-server] GET ${route} → 200`);
|
||||
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 {
|
||||
await kill();
|
||||
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 });
|
||||
} catch {
|
||||
/* best effort — it's under os.tmpdir() */
|
||||
}
|
||||
console.log("[smoke-server] owned server stopped; disposable home removed");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`[smoke-server] FAIL: ${failed.message}`);
|
||||
dumpTail();
|
||||
process.exit(1);
|
||||
}
|
||||
if (failed) { dumpTail(); throw failed; }
|
||||
console.log("[smoke-server] smoke passed — the packaged server boots and serves.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[smoke-server] FAIL: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Disposable inline fixtures only: never require a built payload or workflow artifacts. */
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveSmokeOptions, validateSmokeOptions } from "../scripts/smoke-server.mjs";
|
||||
|
||||
const script = fileURLToPath(new URL("../scripts/smoke-server.mjs", import.meta.url));
|
||||
const checkout = fileURLToPath(new URL("..", import.meta.url));
|
||||
const run = (args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 10_000 });
|
||||
|
||||
function withFixture(test: (root: string) => void) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-options-test-"));
|
||||
try { test(root); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function fails(args: string[], message: RegExp) {
|
||||
const result = run(args);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 1, result.stdout + result.stderr);
|
||||
assert.match(result.stderr, message);
|
||||
assert.doesNotMatch(result.stdout, /boot ok|smoke passed|import ok/);
|
||||
}
|
||||
|
||||
describe("standalone server smoke options", () => {
|
||||
it("preserves the checkout staging default and host runtime without booting on import", () => {
|
||||
const options = resolveSmokeOptions();
|
||||
assert.equal(options.standalone, path.join(checkout, ".next", "standalone"));
|
||||
assert.equal(options.server, path.join(options.standalone, "server.js"));
|
||||
assert.equal(options.runtime, process.execPath);
|
||||
});
|
||||
|
||||
it("resolves explicit relative paths against the caller and accepts an explicit root", () => {
|
||||
const relative = resolveSmokeOptions(["--standalone", "payload", "--runtime", "bin/runtime"]);
|
||||
assert.equal(relative.standalone, path.resolve("payload"));
|
||||
assert.equal(relative.runtime, path.resolve("bin/runtime"));
|
||||
withFixture((root) => {
|
||||
fs.writeFileSync(path.join(root, "server.js"), "// fixture; never booted");
|
||||
const options = resolveSmokeOptions(["--runtime", process.execPath, "--standalone", root]);
|
||||
assert.equal(options.server, path.join(root, "server.js"));
|
||||
assert.doesNotThrow(() => validateSmokeOptions(options));
|
||||
});
|
||||
});
|
||||
|
||||
for (const option of ["--standalone", "--runtime"]) {
|
||||
for (const suffix of [[], [""], ["--electron"]]) {
|
||||
it(`rejects missing or malformed ${option} value ${JSON.stringify(suffix)}`, () => {
|
||||
fails([option, ...suffix], new RegExp(`${option} requires a path`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("rejects unknown flags and conflicting runtime selectors", () => {
|
||||
fails(["--unknown"], /Unknown option/);
|
||||
fails(["--electron", "--runtime", process.execPath], /Use either --electron or --runtime/);
|
||||
});
|
||||
|
||||
it("retains the legacy Electron runtime selector", () => {
|
||||
assert.match(resolveSmokeOptions(["--electron"]).runtime, /electron/);
|
||||
});
|
||||
|
||||
it("fails early for nonexistent and non-directory roots", () => {
|
||||
withFixture((root) => {
|
||||
fails(["--standalone", path.join(root, "absent")], /Standalone directory not found/);
|
||||
const file = path.join(root, "file");
|
||||
fs.writeFileSync(file, "fixture");
|
||||
fails(["--standalone", file], /Standalone directory not found/);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails early for missing or non-file server entries", () => {
|
||||
withFixture((root) => {
|
||||
fails(["--standalone", root], /Standalone server not found/);
|
||||
fs.mkdirSync(path.join(root, "server.js"));
|
||||
fails(["--standalone", root], /Standalone server not found/);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails early for missing, non-file and non-executable runtimes", () => {
|
||||
withFixture((root) => {
|
||||
fs.writeFileSync(path.join(root, "server.js"), "// fixture");
|
||||
fails(["--standalone", root, "--runtime", path.join(root, "missing")], /Runtime not found/);
|
||||
fails(["--standalone", root, "--runtime", root], /Runtime not found/);
|
||||
const runtime = path.join(root, "runtime");
|
||||
fs.writeFileSync(runtime, "fixture", { mode: 0o600 });
|
||||
fails(["--standalone", root, "--runtime", runtime], /EACCES/);
|
||||
});
|
||||
});
|
||||
|
||||
for (const relative of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) {
|
||||
it(`rejects leaked credential/data path ${relative} before dependency probes`, () => {
|
||||
withFixture((root) => {
|
||||
fs.writeFileSync(path.join(root, "server.js"), "// fixture");
|
||||
fs.writeFileSync(path.join(root, relative), "not-a-real-credential");
|
||||
fails(["--standalone", root], /Credential\/data path leaked into standalone/);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user