Compare commits

...
10 changed files with 800 additions and 13 deletions
+14
View File
@@ -169,6 +169,20 @@ jobs:
- name: Windows/Linux rehearsal (no publish)
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
run: npx electron-builder ${{ matrix.builder_args }} --publish never
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
run: |
case "$RUNNER_OS" in
macOS)
app="dist/mac-arm64/LearnHarness.app/Contents"
node scripts/smoke-demo-runtime.mjs --standalone "$app/Resources/server/standalone" --runtime "$app/MacOS/LearnHarness" --browser
;;
Windows)
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
;;
Linux)
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
;;
esac
- name: Save installers and update metadata
uses: actions/upload-artifact@v7
with:
+94
View File
@@ -0,0 +1,94 @@
// No Electron import: the shipping helper's readiness contract is testable
// without a display. Never announce CDP until its exact loopback endpoint works.
const http = require("node:http");
/**
* @param {number} port
* @param {{signal?: AbortSignal, timeoutMs?: number, requestTimeoutMs?: number, retryMs?: number}} [options]
* @returns {Promise<string>}
*/
function waitForBrowserService(port, {
signal,
timeoutMs = 5000,
requestTimeoutMs = 500,
retryMs = 50,
} = {}) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.reject(new Error("browser-service: invalid loopback port"));
}
return new Promise((resolve, reject) => {
let settled = false;
let activeRequest;
let attemptTimer;
let retryTimer;
let lastError;
const finish = (error, endpoint) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
clearTimeout(attemptTimer);
clearTimeout(retryTimer);
signal?.removeEventListener("abort", abort);
activeRequest?.destroy();
if (error) reject(error);
else resolve(endpoint);
};
const abort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("browser-service readiness aborted"));
const deadline = setTimeout(() => finish(new Error(
`browser-service: http://127.0.0.1:${port}/json/version not ready within ${timeoutMs}ms`,
{ cause: lastError },
)), timeoutMs);
const attempt = () => {
if (settled) return;
let done = false;
let response;
const complete = (error, endpoint) => {
if (done || settled) return;
done = true;
clearTimeout(attemptTimer);
response?.destroy();
request.destroy();
activeRequest = undefined;
if (!error) return finish(null, endpoint);
lastError = error;
retryTimer = setTimeout(attempt, retryMs);
};
const request = http.get({
hostname: "127.0.0.1", port, path: "/json/version", agent: false,
}, (res) => {
response = res;
res.on("error", (error) => complete(error));
if (res.statusCode !== 200) return complete(new Error(`CDP HTTP status ${res.statusCode}`));
const chunks = [];
let bytes = 0;
res.on("data", (chunk) => {
if (done || settled) return;
bytes += chunk.length;
if (bytes > 16 * 1024) return complete(new Error("CDP response exceeds 16 KiB"));
chunks.push(chunk);
});
res.on("end", () => {
try {
const info = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const endpoint = new URL(info.webSocketDebuggerUrl);
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || endpoint.port !== String(port) ||
endpoint.username || endpoint.password || endpoint.search || endpoint.hash ||
endpoint.href !== `ws://127.0.0.1:${port}${endpoint.pathname}` ||
!/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(endpoint.pathname)) {
throw new Error("CDP response has an invalid browser WebSocket endpoint");
}
complete(null, endpoint.href);
} catch (error) { complete(error); }
});
});
activeRequest = request;
request.on("error", (error) => complete(error));
attemptTimer = setTimeout(() => complete(new Error("CDP HTTP request timed out")), requestTimeoutMs);
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
else attempt();
});
}
module.exports = { waitForBrowserService };
+18 -7
View File
@@ -64,13 +64,24 @@ function serverEntry() {
// <this binary> --browser-service
// and connects over CDP (system Chrome stays the fallback). Handshake:
// the helper prints "BROWSER_SERVICE_CDP <port>" on stdout once the CDP
// endpoint listens. Lifetime: stdin stays open; parent death = EOF = quit.
// endpoint listens. Lifetime: parent IPC disconnect/death = quit.
// MUST run before the single-instance lock — a helper is a second
// instance of this very binary, and before the LH_DEV_URL redirect — the
// helper owns no user data at all.
if (process.argv.includes("--browser-service")) {
const { waitForBrowserService } = require("./browser-service-readiness.cjs");
// Own the helper's entire startup lifetime, including the readiness wait.
const lifetime = new AbortController();
const parentClosed = () => {
lifetime.abort(new Error("browser-service parent IPC disconnected"));
app.quit();
};
(async () => {
try {
// Full Windows Electron replaces stdin with synthetic EOF. Use the
// real Node IPC channel, and refuse to start an unowned helper.
if (!process.channel || !process.connected) throw new Error("browser-service requires connected parent IPC");
process.once("disconnect", parentClosed);
app.dock?.hide();
const port = await freePort();
// Must land before app-ready for Chromium to pick it up.
@@ -80,13 +91,13 @@ if (process.argv.includes("--browser-service")) {
// platform (no window-all-closed handler is installed here).
const win = new BrowserWindow({ show: false });
await win.loadURL("about:blank");
console.log(`BROWSER_SERVICE_CDP ${port}`);
// Parent-tied lifetime: the server holds our stdin open.
process.stdin.resume();
process.stdin.on("end", () => app.quit());
process.stdin.on("close", () => app.quit());
// Window load is not a DevTools HTTP readiness barrier. Confirm the
// endpoint before both production and smoke clients see the handshake.
await waitForBrowserService(port, { signal: lifetime.signal });
if (!lifetime.signal.aborted) console.log(`BROWSER_SERVICE_CDP ${port}`);
} catch (err) {
console.error(`browser-service failed: ${err?.stack ?? err}`);
if (lifetime.signal.aborted) return; // Parent disconnect is normal.
console.error("browser-service failed:", err);
app.exit(1);
}
})();
+3
View File
@@ -55,6 +55,9 @@ const nextConfig: NextConfig = {
// caught it; unit tests run unbundled and never see it.
outputFileTracingIncludes: {
"*": [
// The standalone trace omitted this entire runtime-imported directory
// in 0.3.0, so importing puppeteer-core failed before browser startup.
"node_modules/@puppeteer/browsers/lib/browser-data/**/*.js",
"node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*",
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
+234
View File
@@ -0,0 +1,234 @@
/**
* Import the demo dependency graph from ONLY a standalone payload. With --browser,
* also drive the packaged app's own --browser-service (never system Chrome).
* No server, model, keys, installed app UI, or production diagnostic route needed.
*
* node scripts/smoke-demo-runtime.mjs --standalone <dir> --runtime <electron binary> [--browser]
* --runtime defaults to host Node for the import-only gate.
*/
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire, registerHooks } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/** @param {Record<string, string | undefined>} env @param {string} home @returns {Record<string, string | undefined>} */
export function probeEnv(env, home) {
const clean = Object.fromEntries(Object.entries(env).filter(([key]) =>
!/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key) &&
!/^(AWS_|GOOGLE_|GCLOUD_|CLOUDSDK_|AZURE_|ANTHROPIC_|OPENAI_|OPENROUTER_|GEMINI_)/.test(key) &&
!["NODE_OPTIONS", "NODE_PATH", "LH_DEV_URL", "ELECTRON_RUN_AS_NODE"].includes(key)));
return {
...clean,
HOME: home, USERPROFILE: home,
APPDATA: path.join(home, "config"), LOCALAPPDATA: path.join(home, "local"),
XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache"),
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
LH_IGNORE_ENV_KEYS: "1", NODE_ENV: "production",
};
}
// Keep fetch's nested cause (including AggregateError socket failures), but
// never dump arbitrary error properties such as request headers or environment.
export function formatProbeError(error, depth = 0) {
if (depth > 5) return "[cause chain truncated]";
if (!error || typeof error !== "object") return String(error);
const lines = [typeof error.stack === "string" ? error.stack : `${error.name ?? "Error"}: ${error.message ?? "unknown error"}`];
for (const field of ["code", "errno", "syscall", "address", "port"]) {
if (["string", "number"].includes(typeof error[field])) lines.push(`${field}: ${error[field]}`);
}
if (error.cause !== undefined) lines.push(`cause: ${formatProbeError(error.cause, depth + 1)}`);
if (Array.isArray(error.errors)) {
for (const nested of error.errors.slice(0, 8)) lines.push(`aggregate: ${formatProbeError(nested, depth + 1)}`);
}
return lines.join("\n");
}
export function verifyDemoRuntime(standalone, runtime = process.execPath, browser = false) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-runtime-"));
try {
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone), ...(browser ? ["--browser"] : [])], {
cwd: home,
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
encoding: "utf8", timeout: 60_000, maxBuffer: 128 * 1024,
});
if (result.status !== 0 || result.error) {
throw new Error(`demo runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
}
process.stdout.write(result.stdout);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}
async function importPayload(standalone) {
const root = fs.realpathSync(standalone);
const inside = (file) => {
const relative = path.relative(root, fs.realpathSync(file));
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
`dependency escaped standalone payload: ${file}`);
};
const require = createRequire(path.join(root, "server.js"));
const entry = require.resolve("puppeteer-core");
inside(entry); // require.resolve must not silently fall back to checkout node_modules.
const loaded = new Set();
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith("file:")) {
inside(fileURLToPath(result.url));
loaded.add(result.url);
} else {
assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
}
return result;
},
});
try {
const { default: puppeteer } = await import(pathToFileURL(entry).href);
assert.equal(typeof puppeteer.connect, "function");
console.log(`[smoke-demo-runtime] puppeteer-core import ok; ${loaded.size} modules confined to ${root}`);
return { puppeteer, hooks };
} catch (error) {
hooks.deregister();
throw error;
}
}
// Explicit IPC disconnect can omit ChildProcess 'close' even after Electron
// exits. Wait for actual exit, boundedly; never infer shutdown intent from
// Node's automatic stdin cleanup after an unexpected child exit.
/** @returns {Promise<{code: number | null, signal: NodeJS.Signals | null, requested: boolean}>} */
export async function stopBrowserService(helper) {
if (!helper.pid || helper.exitCode !== null || helper.signalCode !== null) {
return { code: helper.exitCode, signal: helper.signalCode, requested: false };
}
let onExit, onError;
const exited = new Promise((resolve, reject) => {
onExit = (code, signal) => resolve({ code, signal });
onError = reject;
helper.once("exit", onExit);
helper.once("error", onError);
});
const wait = async (ms) => {
let timer;
try {
return await Promise.race([exited, new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); })]);
} finally { clearTimeout(timer); }
};
try {
const requested = helper.connected;
if (requested) {
console.log("[smoke-demo-runtime] requesting parent IPC disconnect");
helper.disconnect();
}
const result = await wait(5000);
if (!result) {
helper.kill("SIGKILL");
if (!await wait(3000)) throw new Error("browser-service did not exit after forced cleanup");
throw new Error("browser-service did not exit after IPC disconnect; force-killed");
}
return { ...result, requested };
} finally {
helper.removeListener("exit", onExit);
helper.removeListener("error", onError);
}
}
async function browserProbe(puppeteer) {
assert.ok(process.versions.electron, "--browser requires a packaged Electron --runtime binary");
const home = process.env.HOME;
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
delete env.LH_DEV_URL;
const helper = spawn(process.execPath, [
"--browser-service", `--user-data-dir=${path.join(home, "browser-profile")}`,
"--disable-background-networking", "--disable-component-update", "--no-proxy-server",
// Hosted Linux runners disallow Chromium's unprivileged sandbox.
...(process.platform === "linux" ? ["--no-sandbox"] : []),
], { cwd: home, env, stdio: ["ignore", "pipe", "pipe", "ipc"] });
let output = "";
let errors = "";
helper.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-16_000); });
let browser;
let failed = false;
try {
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("browser-service handshake timed out")), 15_000);
helper.once("error", (err) => { clearTimeout(timer); reject(err); });
helper.once("exit", (code) => { clearTimeout(timer); reject(new Error(`browser-service exited early: ${code}`)); });
helper.stdout.on("data", (chunk) => {
output = (output + chunk).slice(-16_000);
const match = output.match(/^BROWSER_SERVICE_CDP (\d+)\r?$/m);
if (match) { clearTimeout(timer); resolve(Number(match[1])); }
});
});
const info = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
browser = await puppeteer.connect({ browserWSEndpoint: info.webSocketDebuggerUrl, defaultViewport: null, protocolTimeout: 10_000 });
// Electron CDP has no Target.createTarget: use the helper's existing page.
const pages = await browser.pages();
const page = pages.find((candidate) => candidate.target().type() === "page");
assert.ok(page, "browser-service has no page target");
const failures = [];
page.on("console", (msg) => { if (msg.type() === "error") failures.push(msg.text()); });
page.on("pageerror", (err) => failures.push(String(err)));
page.on("requestfailed", (req) => failures.push(`failed request: ${req.url()}`));
await page.setRequestInterception(true);
page.on("request", (req) => {
if (/^(file:|about:|data:)/.test(req.url())) void req.continue();
else { failures.push(`unexpected network request: ${req.url()}`); void req.abort(); }
});
const html = path.join(home, "demo.html");
fs.writeFileSync(html, '<!doctype html><meta charset="utf-8"><button id="increment">Increment</button><output id="count">0</output><script>document.querySelector("#increment").onclick = () => { document.querySelector("#count").textContent = "1"; };</script>');
await page.goto(pathToFileURL(html).href, { waitUntil: "load", timeout: 10_000 });
assert.equal(await page.$eval("#count", (element) => element.textContent), "0");
await page.click("#increment");
assert.equal(await page.$eval("#count", (element) => element.textContent), "1");
await page.goto("about:blank");
assert.equal(await page.$("#increment"), null, "page state was not cleared");
assert.deepEqual(failures, [], "demo console/page/network errors");
console.log("[smoke-demo-runtime] packaged browser-service: JS button 0 → 1, clean console/page, page reset ok (no Chrome fallback)");
} catch (err) {
failed = true;
throw new Error(`browser-service probe failed at local CDP/browser interaction\n--- browser-service output ---\n${output}\n${errors}`, { cause: err });
} finally {
browser?.disconnect();
try {
const { code, signal, requested } = await stopBrowserService(helper);
if (requested) console.log(`[smoke-demo-runtime] observed helper exit after requested IPC disconnect: code=${code}, signal=${signal} (no resident helper)`);
else console.log(`[smoke-demo-runtime] helper already exited/disconnected before cleanup: code=${code}, signal=${signal}; no shutdown requested`);
if (!failed) {
assert.ok(requested, "browser-service exited/disconnected before requested cleanup");
assert.deepEqual({ code, signal }, { code: 0, signal: null }, "browser-service shutdown failed");
}
} catch (error) {
if (!failed) throw error;
console.error(`[smoke-demo-runtime] cleanup also failed: ${formatProbeError(error)}`);
}
}
}
async function main() {
const args = process.argv.slice(2);
if (args[0] === "--probe") {
const { puppeteer, hooks } = await importPayload(args[1]);
try {
if (args.includes("--browser")) await browserProbe(puppeteer);
} finally { hooks.deregister(); }
return;
}
const value = (name, fallback) => {
const index = args.indexOf(name);
if (index < 0) return fallback;
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
return path.resolve(args[index + 1]);
};
verifyDemoRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath), args.includes("--browser"));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err) => { console.error(`[smoke-demo-runtime] FAIL: ${formatProbeError(err)}`); process.exitCode = 1; });
}
+5 -1
View File
@@ -25,6 +25,7 @@
* way, same day).
*/
import { spawn } from "node:child_process";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
@@ -76,6 +77,9 @@ function get(url) {
}
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);
const port = await freePort();
const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
@@ -104,7 +108,7 @@ async function main() {
const child = spawn(RUNTIME, [SERVER], {
cwd: STANDALONE,
env: {
...process.env,
...probeEnv(process.env, home),
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production",
PORT: String(port),
+6 -5
View File
@@ -116,8 +116,9 @@ const NO_CHROME_MESSAGE =
// Demos execute JS and need a browser. PRIMARY: this very app's binary
// spawned with --browser-service (main.cjs boots a hidden window with a CDP
// port and prints "BROWSER_SERVICE_CDP <port>"). FALLBACK: system Chrome.
// The helper's stdin is held open by this process; server death = EOF = the
// helper quits — no orphans. PDFs and SVGs never come here (native now).
// A Node IPC channel owns its lifetime: parent death/disconnect quits the
// helper on every OS (Windows Electron stdin is synthetic EOF). No orphans.
// PDFs and SVGs never come here (native now).
/** Handshake line parser (pure; exported for tests). */
export function parseBrowserServiceLine(line: string): number | null {
@@ -180,7 +181,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
// Cast: the sanitizer is pure over a loose record; NODE_ENV is
// always present in a real process.env.
env: browserServiceSpawnEnv(process.env) as NodeJS.ProcessEnv,
stdio: ["pipe", "pipe", "pipe"] as const,
stdio: ["ignore", "pipe", "pipe", "ipc"] as const,
});
browserServiceProc = proc;
port = await new Promise<number | null>((resolve) => {
@@ -197,7 +198,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
resolve(parseBrowserServiceLine(line));
}
};
proc.stdout.on("data", onData);
proc.stdout!.on("data", onData);
proc.once("exit", () => {
clearTimeout(timer);
resolve(null);
@@ -215,7 +216,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
const info = (await version.json()) as { webSocketDebuggerUrl?: string };
if (!info.webSocketDebuggerUrl) return null;
// connect ≠ launch: browser.close() below merely disconnects — the
// helper stays resident for the next job (stdin-held lifetime).
// helper stays resident for the next job (IPC-owned lifetime).
return await puppeteer.connect({
browserWSEndpoint: info.webSocketDebuggerUrl,
defaultViewport: null,
+149
View File
@@ -0,0 +1,149 @@
/** Execute the real helper entry with fake Electron; no display or user profile. */
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname } from "node:path";
import { Readable } from "node:stream";
import { describe, it } from "node:test";
import { setImmediate as turn } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { runInNewContext } from "node:vm";
import { stopBrowserService } from "../scripts/smoke-demo-runtime.mjs";
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainSource = readFileSync(mainFile, "utf8");
const requireMain = createRequire(mainFile);
function helperFixture(connected = true, channel = true) {
let allowAppReady!: () => void;
const appReady = new Promise<void>((resolve) => { allowAppReady = resolve; });
let allowReadiness!: () => void;
const readiness = new Promise<void>((resolve) => { allowReadiness = resolve; });
const state = { quits: 0, exits: [] as number[], windows: 0, readinessCalls: 0, stdinReads: 0, signal: undefined as AbortSignal | undefined };
const logs: string[] = [];
const errors: string[] = [];
// Match Electron 44's Windows override, not a native pipe.
const stdin = new Readable({ read() {} });
stdin.push(null);
const process = Object.assign(new EventEmitter(), {
platform: "win32", argv: ["electron", "--browser-service"], env: {},
connected, channel: channel ? {} : undefined,
});
Object.defineProperty(process, "stdin", { get() { state.stdinReads++; return stdin; } });
const app = {
setName() {}, commandLine: { appendSwitch() {} }, whenReady: () => appReady,
quit() { state.quits++; }, exit(code: number) { state.exits.push(code); },
};
class FakeWindow {
constructor() { state.windows++; }
async loadURL(url: string) { assert.equal(url, "about:blank"); }
}
const net = {
createServer: () => Object.assign(new EventEmitter(), {
listen(_port: number, _host: string, callback: () => void) { callback(); },
address: () => ({ port: 54321 }), close(callback: () => void) { callback(); },
}),
};
const waitForBrowserService = (_port: number, { signal }: { signal: AbortSignal }) => {
state.readinessCalls++;
state.signal = signal;
return new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
readiness.then(resolve);
});
};
runInNewContext(mainSource, {
require: (id: string) => id === "electron" ? { app, BrowserWindow: FakeWindow }
: id === "node:net" ? net
: id === "./browser-service-readiness.cjs" ? { waitForBrowserService }
: requireMain(id),
process, __dirname: dirname(mainFile), AbortController,
console: { log: (line: string) => logs.push(line), error: (...parts: unknown[]) => errors.push(parts.join(" ")) },
}, { filename: mainFile });
return {
state, logs, errors, stdin, process, allowAppReady, allowReadiness,
disconnect() { process.connected = false; process.emit("disconnect"); },
};
}
describe("browser-service IPC-owned lifetime", () => {
it("ignores synthetic Windows stdin EOF while IPC remains connected", async () => {
const helper = helperFixture();
helper.stdin.resume();
await turn();
assert.equal(helper.stdin.readableEnded, true);
assert.equal(helper.state.stdinReads, 0);
assert.equal(helper.state.quits, 0);
helper.allowAppReady();
await turn();
assert.equal(helper.state.readinessCalls, 1);
assert.equal(helper.state.signal?.aborted, false);
helper.allowReadiness();
await turn();
assert.deepEqual(helper.logs, ["BROWSER_SERVICE_CDP 54321"]);
assert.deepEqual(helper.state.exits, []);
helper.disconnect();
assert.equal(helper.state.quits, 1);
});
it("disconnect during readiness aborts it, quits once, and never announces", async () => {
const helper = helperFixture();
helper.allowAppReady();
await turn();
assert.equal(helper.state.readinessCalls, 1);
helper.disconnect();
helper.disconnect();
await turn();
assert.equal(helper.state.signal?.aborted, true);
assert.equal(helper.state.quits, 1);
assert.deepEqual(helper.logs, []);
assert.deepEqual(helper.errors, []);
assert.deepEqual(helper.state.exits, []);
assert.equal(helper.state.stdinReads, 0);
});
it("owns disconnect before app.whenReady resolves", async () => {
const helper = helperFixture();
helper.disconnect();
await turn();
assert.equal(helper.state.quits, 1);
assert.equal(helper.state.windows, 0);
assert.deepEqual(helper.logs, []);
});
it("fails closed without both a connected IPC channel and connection state", async () => {
for (const [connected, channel] of [[false, false], [true, false], [false, true]]) {
const helper = helperFixture(connected, channel);
await turn();
assert.deepEqual(helper.state.exits, [1]);
assert.match(helper.errors.join("\n"), /requires connected parent IPC/);
assert.equal(helper.state.windows, 0);
assert.equal(helper.state.readinessCalls, 0);
assert.equal(helper.state.stdinReads, 0);
assert.equal(helper.process.listenerCount("disconnect"), 0);
}
});
});
describe("browser-service probe cleanup", () => {
it("requests IPC disconnect and accepts actual exit without a close event", async () => {
const helper = Object.assign(new EventEmitter(), {
pid: 123, connected: true, exitCode: null as number | null, signalCode: null,
disconnect() {
helper.connected = false;
queueMicrotask(() => { helper.exitCode = 0; helper.emit("exit", 0, null); });
},
});
assert.deepEqual(await stopBrowserService(helper), { code: 0, signal: null, requested: true });
assert.equal(helper.listenerCount("exit"), 0);
assert.equal(helper.listenerCount("error"), 0);
});
it("does not label an already-exited helper as parent-requested shutdown", async () => {
const helper = { pid: 123, connected: false, exitCode: 0, signalCode: null,
disconnect() { assert.fail("must not request shutdown after early exit"); } };
assert.deepEqual(await stopBrowserService(helper), { code: 0, signal: null, requested: false });
});
});
+170
View File
@@ -0,0 +1,170 @@
/** Shipping helper readiness, with real loopback HTTP but no Electron/display. */
import assert from "node:assert/strict";
import * as http from "node:http";
import type { AddressInfo, Socket } from "node:net";
import { describe, it } from "node:test";
import { setTimeout as delay } from "node:timers/promises";
import { waitForBrowserService } from "../electron/browser-service-readiness.cjs";
const endpoint = (port: number) => `ws://127.0.0.1:${port}/devtools/browser/test-browser-id`;
const version = (port: number) => JSON.stringify({ webSocketDebuggerUrl: endpoint(port) });
const short = { timeoutMs: 250, requestTimeoutMs: 50, retryMs: 10 };
async function withEndpoint(
reply: (request: http.IncomingMessage, response: http.ServerResponse, port: number) => void,
test: (port: number, idle: () => Promise<void>) => Promise<void>,
) {
let port = 0;
const sockets = new Set<Socket>();
const server = http.createServer((request, response) => reply(request, response, port));
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
const idle = async () => {
const deadline = Date.now() + 1000;
while (sockets.size && Date.now() < deadline) await delay(10);
assert.equal(sockets.size, 0, "readiness left an HTTP connection open");
};
try { await test(port, idle); }
finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
describe("browser-service shipping readiness", () => {
it("waits for a TCP endpoint that starts listening after readiness began", async () => {
const server = http.createServer((_req, res) => res.end(version(port)));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
let ready = false;
const waiting = waitForBrowserService(port, { timeoutMs: 2000, requestTimeoutMs: 500, retryMs: 10 }).then((value) => { ready = true; return value; });
try {
await delay(40);
assert.equal(ready, false, "announced readiness before the endpoint listened");
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
assert.equal(await waiting, endpoint(port));
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("waits through delayed/non-200 startup and only accepts the exact CDP endpoint", async () => {
let requests = 0;
await withEndpoint((req, res, port) => {
assert.equal(req.url, "/json/version");
assert.equal(req.headers.host, `127.0.0.1:${port}`);
if (++requests < 3) { res.writeHead(503); res.end(); }
else setTimeout(() => res.end(version(port)), 20);
}, async (port, idle) => {
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
assert.equal(requests, 3);
await idle();
await delay(40);
assert.equal(requests, 3, "polling continued after readiness");
});
});
it("retries malformed JSON and a missing browser WebSocket endpoint", async () => {
let requests = 0;
await withEndpoint((_req, res, port) => {
requests++;
res.end(requests === 1 ? "not json" : requests === 2 ? "{}" : version(port));
}, async (port, idle) => {
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
assert.equal(requests, 3);
await idle();
});
});
it("rejects arbitrary, wrong-port, credentialed, and non-browser WebSocket URLs", async () => {
const invalid = [
"ws://example.com:1234/devtools/browser/id",
"ws://127.0.0.1:1/devtools/browser/id",
"wss://127.0.0.1:1234/devtools/browser/id",
"ws://user:password@127.0.0.1:1234/devtools/browser/id",
"ws://127.0.0.1:1234/devtools/page/id",
"ws://127.0.0.1:1234/devtools/browser/id?query=1",
"ws://127.0.0.1:1234/devtools/browser/id#fragment",
"ws://127.0.0.1:1234/devtools/browser/",
];
let requests = 0;
await withEndpoint((_req, res, port) => {
const bad = invalid[requests++];
res.end(bad ? JSON.stringify({ webSocketDebuggerUrl: bad.replaceAll(":1234", `:${port}`) }) : version(port));
}, async (port, idle) => {
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
assert.equal(requests, invalid.length + 1);
await idle();
});
});
it("bounds response size and retries instead of accepting oversized data", async () => {
let requests = 0;
await withEndpoint((_req, res, port) => {
res.end(++requests === 1 ? "x".repeat(16 * 1024 + 1) : version(port));
}, async (port, idle) => {
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
assert.equal(requests, 2);
await idle();
});
});
it("fails within the readiness budget and retains the last HTTP failure", async () => {
let requests = 0;
await withEndpoint((_req, res) => { requests++; res.writeHead(503); res.end(); }, async (port, idle) => {
const start = Date.now();
await assert.rejects(waitForBrowserService(port, short), (error: Error) => {
assert.match(error.message, /not ready within 250ms/);
assert.match(String(error.cause), /CDP HTTP status 503/);
return true;
});
assert.ok(Date.now() - start < 2000, "readiness exceeded its bounded budget");
await idle();
const stopped = requests;
await delay(40);
assert.equal(requests, stopped, "polling continued after timeout");
});
});
it("destroys hung requests on the per-request and overall timeouts", async () => {
let requests = 0;
await withEndpoint(() => { requests++; }, async (port, idle) => {
await assert.rejects(waitForBrowserService(port, short), /not ready within/);
assert.ok(requests >= 2, "hung attempt was not bounded before the overall deadline");
await idle();
});
});
it("aborts a hung request on parent EOF and leaves no polling/connection behind", async () => {
const controller = new AbortController();
let started!: () => void;
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
let requests = 0;
await withEndpoint(() => { requests++; started(); }, async (port, idle) => {
const waiting = waitForBrowserService(port, { signal: controller.signal });
await requestStarted;
controller.abort(new Error("parent EOF"));
await assert.rejects(waiting, /parent EOF/);
await idle();
await delay(80);
assert.equal(requests, 1);
});
});
it("does not start any request if the parent already closed", async () => {
const controller = new AbortController();
controller.abort(new Error("parent EOF"));
let requests = 0;
await withEndpoint(() => { requests++; }, async (port) => {
await assert.rejects(waitForBrowserService(port, { signal: controller.signal }), /parent EOF/);
await delay(40);
assert.equal(requests, 0);
});
});
});
+107
View File
@@ -0,0 +1,107 @@
/** No display, real browser, installed app, or production dependency tree needed. */
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, pathToFileURL } from "node:url";
import { formatProbeError, probeEnv } from "../scripts/smoke-demo-runtime.mjs";
const script = fileURLToPath(new URL("../scripts/smoke-demo-runtime.mjs", import.meta.url));
function withPayload(test: (fixture: {
root: string;
write: (relative: string, content: string) => void;
run: () => ReturnType<typeof spawnSync>;
}) => void) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-payload-test-"));
const write = (relative: string, content: string) => {
const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
};
try {
write("server.js", "// fixture");
write("node_modules/puppeteer-core/package.json", JSON.stringify({ name: "puppeteer-core", type: "module", main: "lib/index.js" }));
write("node_modules/puppeteer-core/lib/index.js", 'import "./browser-data/browser-data.js"; export default { connect() {} };');
test({ root, write, run: () => spawnSync(process.execPath, [script, "--standalone", root], {
encoding: "utf8", timeout: 10_000,
}) });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
describe("demo standalone dependency gate", () => {
it("imports a complete payload and does not change it", () => {
withPayload(({ root, write, run }) => {
write("node_modules/puppeteer-core/lib/browser-data/browser-data.js", "export const fixture = true;");
const before = fs.readdirSync(root, { recursive: true });
const result = run();
assert.equal(result.status, 0, String(result.stderr));
assert.match(String(result.stdout), /puppeteer-core import ok/);
assert.deepEqual(fs.readdirSync(root, { recursive: true }), before);
});
});
it("fails when a transitive runtime subdirectory is missing", () => {
withPayload(({ root, run }) => {
const before = fs.readdirSync(root, { recursive: true });
const result = run();
assert.equal(result.status, 1);
assert.match(String(result.stderr), /ERR_MODULE_NOT_FOUND/);
assert.match(String(result.stderr), /browser-data/);
assert.deepEqual(fs.readdirSync(root, { recursive: true }), before);
});
});
it("rejects imports outside the payload even when they resolve successfully", () => {
withPayload(({ write, run }) => {
// A sibling file, not just a missing file: resolution succeeds but confinement must fail.
const sibling = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-outside-"));
try {
const external = path.join(sibling, "dependency.mjs");
fs.writeFileSync(external, "export const outside = true;");
write("node_modules/puppeteer-core/lib/index.js", `import ${JSON.stringify(pathToFileURL(external).href)}; export default { connect() {} };`);
const result = run();
assert.equal(result.status, 1);
assert.match(String(result.stderr), /dependency escaped standalone payload/);
} finally { fs.rmSync(sibling, { recursive: true, force: true }); }
});
});
it("cannot substitute the checkout's Puppeteer for an absent payload package", () => {
withPayload(({ root, run }) => {
fs.rmSync(path.join(root, "node_modules"), { recursive: true });
const result = run();
assert.equal(result.status, 1);
assert.doesNotMatch(String(result.stdout), /import ok/);
});
});
it("preserves nested fetch/socket causes without dumping headers or environment", () => {
const socket = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED", address: "127.0.0.1", port: 54321 });
const cause = new AggregateError([socket], "connection failed");
const error = Object.assign(new TypeError("fetch failed", { cause }), { headers: { authorization: "private-header" }, env: { TOKEN: "private-token" } });
const output = formatProbeError(error);
assert.match(output, /fetch failed/);
assert.match(output, /connection failed/);
assert.match(output, /ECONNREFUSED/);
assert.match(output, /127\.0\.0\.1/);
assert.match(output, /54321/);
assert.doesNotMatch(output, /private-header|private-token|authorization|TOKEN/);
});
it("strips secrets and uses only scratch data/auth/profile locations", () => {
const original = { PATH: "/bin", API_KEY: "fake", GH_TOKEN: "fake", MY_SECRET: "fake", PASSWORD: "fake", NODE_OPTIONS: "--require anything", NODE_PATH: "/outside", ELECTRON_RUN_AS_NODE: "1", LH_DEV_URL: "http://localhost", LH_DATA_DIR: "/real", LH_AUTH_FILE: "/real/auth.json", GOOGLE_APPLICATION_CREDENTIALS: "/real/google.json", AWS_SHARED_CREDENTIALS_FILE: "/real/aws", AWS_PROFILE: "real-profile", CLOUDSDK_CONFIG: "/real/gcloud" };
const env = probeEnv(original, "/scratch");
for (const key of ["API_KEY", "GH_TOKEN", "MY_SECRET", "PASSWORD", "NODE_OPTIONS", "NODE_PATH", "ELECTRON_RUN_AS_NODE", "LH_DEV_URL", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "CLOUDSDK_CONFIG"]) assert.equal(key in env, false, key);
assert.equal(env.PATH, "/bin");
assert.equal(env.LH_DATA_DIR, path.join("/scratch", "data"));
assert.equal(env.LH_AUTH_FILE, path.join("/scratch", "auth.json"));
assert.equal(env.LH_IGNORE_ENV_KEYS, "1");
assert.equal(env.HOME, "/scratch");
assert.equal(original.API_KEY, "fake");
});
});