返回 DeepSeek-Reasonix
smoke.mjs
根目录 / desktop / packaging / smoke.mjs
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
155 lines Plain Text