| 1 | #!/usr/bin/env node |
| 2 | // Launches a packaged shell against a disposable data home and proves the |
| 3 | // desktop/hello handshake reached ready, then uses Electron's normal app.quit |
| 4 | // lifecycle through Playwright and checks both Electron and Go are gone. |
| 5 | // |
| 6 | // usage: node desktop/packaging/smoke.mjs <Reasonix.app|app-dir|executable> |
| 7 | // [--service <reasonix-desktop path>] [--hold <seconds>] [--timeout <seconds>] [--keep-home] |
| 8 | import { spawnSync } from "node:child_process"; |
| 9 | import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 10 | import { createRequire } from "node:module"; |
| 11 | import { tmpdir } from "node:os"; |
| 12 | import { basename, join, resolve } from "node:path"; |
| 13 | import { isDirectory, PRODUCT } from "./lib.mjs"; |
| 14 | import { closeAndVerify, processAlive, sleep, waitForProcessesToExit } from "./smoke-lifecycle.mjs"; |
| 15 | import { packagedSmokeEnv } from "./smoke-env.mjs"; |
| 16 | import { parseServiceReady } from "./smoke-poll.mjs"; |
| 17 | |
| 18 | // Playwright belongs to the Electron workspace, not the shipped application. |
| 19 | const require = createRequire(new URL("../electron/package.json", import.meta.url)); |
| 20 | const { _electron: electron } = require("playwright"); |
| 21 | |
| 22 | const args = process.argv.slice(2); |
| 23 | const option = (name, fallback) => { |
| 24 | const index = args.indexOf(name); |
| 25 | return index >= 0 ? args[index + 1] : fallback; |
| 26 | }; |
| 27 | const targetArg = args.find((arg, index) => !arg.startsWith("--") && (index === 0 || !args[index - 1].startsWith("--") || args[index - 1] === "--keep-home")); |
| 28 | if (!targetArg) { |
| 29 | console.error("usage: smoke.mjs <Reasonix.app|app-dir|executable> [--service <path>] [--hold <seconds>] [--timeout <seconds>] [--keep-home]"); |
| 30 | process.exit(2); |
| 31 | } |
| 32 | const hold = Number(option("--hold", "5")) * 1000; |
| 33 | const timeout = Number(option("--timeout", "60")) * 1000; |
| 34 | const service = option("--service", ""); |
| 35 | const keepHome = args.includes("--keep-home"); |
| 36 | |
| 37 | function executableOf(path) { |
| 38 | const full = resolve(path); |
| 39 | if (!isDirectory(full)) return full; |
| 40 | if (basename(full).endsWith(".app")) return join(full, "Contents", "MacOS", PRODUCT.executable); |
| 41 | for (const name of [`${PRODUCT.executable}.exe`, PRODUCT.executable]) { |
| 42 | if (existsSync(join(full, name))) return join(full, name); |
| 43 | } |
| 44 | throw new Error(`no ${PRODUCT.executable} executable inside ${full}`); |
| 45 | } |
| 46 | |
| 47 | const executable = executableOf(targetArg); |
| 48 | if (!existsSync(executable)) throw new Error(`shell executable is missing: ${executable}`); |
| 49 | const home = mkdtempSync(join(tmpdir(), "reasonix-smoke-")); |
| 50 | const logs = join(home, "desktop-shell", "logs"); |
| 51 | const env = packagedSmokeEnv(process.env, home); |
| 52 | if (service !== "") env.REASONIX_DESKTOP_SERVICE = resolve(service); |
| 53 | const stdio = join(home, "smoke-stdio.log"); |
| 54 | const started = Date.now(); |
| 55 | let child; |
| 56 | let shellPid; |
| 57 | let exit = null; |
| 58 | let ready = null; |
| 59 | const captureOutput = (data) => appendFileSync(stdio, data); |
| 60 | |
| 61 | const readLog = (name) => { |
| 62 | try { |
| 63 | return readFileSync(join(logs, name), "utf8"); |
| 64 | } catch { |
| 65 | return ""; |
| 66 | } |
| 67 | }; |
| 68 | const tail = (name, lines = 40) => readLog(name).trimEnd().split("\n").slice(-lines).join("\n"); |
| 69 | async function cleanupAfterFailure() { |
| 70 | const pids = [...new Set([child?.pid, shellPid, ready?.pid].filter(Number.isInteger))]; |
| 71 | for (const pid of pids) { |
| 72 | if (!processAlive(pid)) continue; |
| 73 | if (process.platform === "win32") { |
| 74 | const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { encoding: "utf8" }); |
| 75 | if (result.error || (result.status !== 0 && processAlive(pid))) { |
| 76 | throw new Error(`failed cleanup of pid ${pid}: ${result.error ?? result.stderr ?? result.status}`); |
| 77 | } |
| 78 | } else { |
| 79 | process.kill(pid, "SIGKILL"); |
| 80 | } |
| 81 | } |
| 82 | await waitForProcessesToExit(pids); |
| 83 | } |
| 84 | |
| 85 | // Resolves once any live window's app bridge answers Version. A page that is |
| 86 | // still on the starting page, mid-navigation, or already destroyed throws |
| 87 | // from evaluate; every such throw is a reason to scan again, not to fail. |
| 88 | async function appVersion(application, deadline) { |
| 89 | let lastError = "no window has exposed window.reasonixDesktop yet"; |
| 90 | for (;;) { |
| 91 | for (const page of application.windows()) { |
| 92 | try { |
| 93 | return await page.evaluate(() => window.reasonixDesktop.invoke("Version", [])); |
| 94 | } catch (error) { |
| 95 | lastError = error.message; |
| 96 | } |
| 97 | } |
| 98 | if (Date.now() > deadline) throw new Error(`renderer never invoked the production service: ${lastError}`); |
| 99 | await sleep(250); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | try { |
| 104 | const application = await electron.launch({ executablePath: executable, args: [], env, timeout }); |
| 105 | child = application.process(); |
| 106 | child.on("exit", (code, signal) => { exit = { code, signal }; }); |
| 107 | child.stdout?.on("data", captureOutput); |
| 108 | child.stderr?.on("data", captureOutput); |
| 109 | // On Windows Playwright may own a cmd.exe wrapper. Read the Electron main |
| 110 | // PID itself so a wrapper exit cannot pass the shell-liveness assertion. |
| 111 | shellPid = await application.evaluate(() => process.pid); |
| 112 | const identity = await application.evaluate(({ app }) => ({ packaged: app.isPackaged, dev: process.env.REASONIX_DEV ?? "", resourcesPath: process.resourcesPath })); |
| 113 | if (!identity.packaged || identity.dev !== "") throw new Error("startup smoke must exercise a packaged app without development mode"); |
| 114 | while (!ready) { |
| 115 | if (exit || !processAlive(shellPid)) throw new Error("shell exited before the handshake"); |
| 116 | if (Date.now() - started > timeout) throw new Error(`no handshake within ${timeout / 1000}s`); |
| 117 | const log = readLog("shell.log"); |
| 118 | const failed = /desktop service failed: .*/.exec(log); |
| 119 | if (failed) throw new Error(failed[0]); |
| 120 | ready = parseServiceReady(log); |
| 121 | if (!ready) await sleep(250); |
| 122 | } |
| 123 | console.log(`PASS handshake ready after ${((Date.now() - started) / 1000).toFixed(1)}s: ${ready.line}`); |
| 124 | // MainWindow.prepareApp replaces the starting-page window with a fresh |
| 125 | // BrowserWindow once the service reports its geometry, and onReady calls it |
| 126 | // right after logging the line polled above. A handle from firstWindow() |
| 127 | // taken in that gap is destroyed under us ("Target page, context or browser |
| 128 | // has been closed"). Wait for whichever live window answers instead. |
| 129 | const version = await appVersion(application, started + timeout); |
| 130 | const expected = JSON.parse(readFileSync(join(identity.resourcesPath, "build.json"), "utf8")).version; |
| 131 | if (version === "dev" || version !== expected) throw new Error(`packaged service version ${version} differs from manifest ${expected}`); |
| 132 | console.log(`PASS renderer invokes the production service: Version=${version}`); |
| 133 | await sleep(hold); |
| 134 | if (exit || !processAlive(shellPid)) throw new Error(`shell exited during the ${hold / 1000}s hold`); |
| 135 | if (!processAlive(ready.pid)) throw new Error(`Go service pid ${ready.pid} exited during the hold`); |
| 136 | console.log(`PASS shell pid ${shellPid} and Go service pid ${ready.pid} still running after ${hold / 1000}s hold`); |
| 137 | |
| 138 | await closeAndVerify(application, { shellPid, servicePid: ready.pid }); |
| 139 | if (exit?.signal || (exit?.code != null && exit.code !== 0)) { |
| 140 | throw new Error(`normal app quit failed (code ${exit.code}, signal ${exit.signal})`); |
| 141 | } |
| 142 | console.log(`PASS normal app quit completed; shell pid ${shellPid} exited`); |
| 143 | console.log(`PASS Go service pid ${ready.pid} exited with the shell`); |
| 144 | } catch (error) { |
| 145 | process.exitCode = 1; |
| 146 | console.error(`FAIL ${error.message}`); |
| 147 | console.error(`--- shell.log ---\n${tail("shell.log")}\n--- service.log ---\n${tail("service.log")}\n--- stdio ---\n${tail("../../smoke-stdio.log")}`); |
| 148 | try { await cleanupAfterFailure(); } catch (cleanupError) { console.error(`FAIL cleanup: ${cleanupError.message}`); } |
| 149 | } finally { |
| 150 | child?.stdout?.off("data", captureOutput); |
| 151 | child?.stderr?.off("data", captureOutput); |
| 152 | if (keepHome || process.exitCode) console.log(`home kept at ${home}`); |
| 153 | else rmSync(home, { recursive: true, force: true }); |
| 154 | } |
| 155 |