| 1 | import { execFileSync } from "node:child_process"; |
| 2 | import { mkdtempSync, copyFileSync, rmSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import { dirname, join, resolve } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { build } from "esbuild"; |
| 9 | import { buildBrowserPage } from "./build-browser-page.mjs"; |
| 10 | |
| 11 | const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 12 | const prepare = process.argv[2] === "--prepare-only" ? process.argv[3] : undefined; |
| 13 | if (process.argv.length > 2 && (!prepare || process.argv.length !== 4)) throw new Error("usage: browser-runtime-smoke.mjs [--prepare-only <directory>]"); |
| 14 | const scratch = mkdtempSync(join(tmpdir(), "reasonix-browser-smoke-")); |
| 15 | try { |
| 16 | await buildBrowserPage(); |
| 17 | for (const name of ["browser-page.js", "browser-page.json", "browser-recorder.js", "recorder-preload.cjs"]) copyFileSync(join(root, "dist", name), join(scratch, name)); |
| 18 | // Top-level await lives inside an async wrapper; the code still uses the |
| 19 | // production main-process minification and function-name settings. |
| 20 | const source = readFileSync(join(root, "src/main/browser/browserRuntime.native.ts"), "utf8"); |
| 21 | const imports = source.split("\n").filter(line => line.startsWith("import ")).join("\n"); |
| 22 | const body = source.split("\n").filter(line => !line.startsWith("import ")).join("\n"); |
| 23 | await build({ stdin: { contents: `${imports}\n(async () => {${body}})().then(() => app.quit(), error => { console.error(error); app.exit(1); });`, resolveDir: join(root, "src/main/browser"), loader: "ts" }, outfile: join(scratch, "main.cjs"), bundle: true, platform: "node", format: "cjs", minify: true, keepNames: true, external: ["electron"] }); |
| 24 | const env = { ...process.env }; |
| 25 | await build({ entryPoints: [join(root, "src/main/browser/guestPreload.ts")], outfile: join(scratch, "guest-preload.cjs"), bundle: true, platform: "node", format: "cjs", external: ["electron"] }); |
| 26 | if (prepare) { |
| 27 | // Stage the identical production-parameter fixture for a native VM. This |
| 28 | // mode performs no validation; the native runner must produce result.json. |
| 29 | mkdirSync(prepare, { recursive: true }); |
| 30 | for (const name of ["main.cjs", "guest-preload.cjs", "browser-page.js", "browser-page.json", "browser-recorder.js", "recorder-preload.cjs"]) copyFileSync(join(scratch, name), join(prepare, name)); |
| 31 | console.log(`Native fixture prepared (not executed): ${prepare}`); |
| 32 | } else { |
| 33 | delete env.ELECTRON_RUN_AS_NODE; |
| 34 | // Electron #51488: AppKit may reopen a crash-state modal despite an isolated |
| 35 | // Chromium profile. Apply the official workaround to this process only. |
| 36 | const platformArgs = process.platform === "darwin" ? ["-ApplePersistenceIgnoreState", "YES"] : []; |
| 37 | const recordingSeconds = Number(process.env.REASONIX_BROWSER_RECORD_SECONDS ?? 1); |
| 38 | if (!Number.isInteger(recordingSeconds) || recordingSeconds < 1 || recordingSeconds > 90) throw new Error("recording test duration must be 1–90 seconds"); |
| 39 | const timeout = Math.max(process.env.REASONIX_BROWSER_CYCLES === "100" ? 180_000 : 60_000, recordingSeconds === 1 ? 0 : (recordingSeconds + 60) * 1000); |
| 40 | const evidence = process.env.REASONIX_BROWSER_EVIDENCE_PATH || join(root, "artifacts/browser-runtime", process.platform); |
| 41 | mkdirSync(evidence, { recursive: true }); |
| 42 | // A failed attempt must not leave a previous run's success as its status. |
| 43 | writeFileSync(join(evidence, "result.json"), JSON.stringify({ completed: false, status: "running", platform: process.platform })); |
| 44 | try { |
| 45 | execFileSync(createRequire(import.meta.url)("electron"), [join(scratch, "main.cjs"), ...platformArgs, `--user-data-dir=${join(scratch, "data")}`], { env, stdio: "inherit", timeout, killSignal: "SIGKILL" }); |
| 46 | assert.equal(JSON.parse(readFileSync(join(scratch, "result.json"), "utf8")).completed, true, "native runner exited before completing all assertions"); |
| 47 | } catch (error) { |
| 48 | writeFileSync(join(evidence, "result.json"), JSON.stringify({ completed: false, status: "failed", platform: process.platform, exitCode: error.status ?? null, signal: error.signal ?? null })); |
| 49 | throw error; |
| 50 | } |
| 51 | for (const name of ["result.json", "browser-evidence.png", "browser-recording.webm", "browser-diagnostics.json"]) copyFileSync(join(scratch, name), join(evidence, name)); |
| 52 | console.log(`Browser runtime evidence: ${evidence}`); |
| 53 | } |
| 54 | } finally { rmSync(scratch, { recursive: true, force: true }); } |
| 55 |