返回 DeepSeek-Reasonix
smoke.mjs
根目录 / desktop / electron / scripts / smoke.mjs
1 #!/usr/bin/env node
2 // Launches the real shell against a built Go service in a disposable data
3 // home, proves the handshake, the renderer bridge, a business command and a
4 // clean exit, and writes the evidence to artifacts/smoke/. No mock is
5 // involved: a missing service or a failed hello fails the run.
6 import { execFileSync } from "node:child_process";
7 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8 import { createRequire } from "node:module";
9 import { tmpdir } from "node:os";
10 import { dirname, join, resolve } from "node:path";
11 import { fileURLToPath } from "node:url";
12
13 const require = createRequire(import.meta.url);
14 const { _electron: electron } = require("playwright");
15
16 const root = dirname(dirname(fileURLToPath(import.meta.url)));
17 const artifacts = resolve(root, "artifacts", "smoke");
18 mkdirSync(artifacts, { recursive: true });
19 const service = process.env.REASONIX_DESKTOP_SERVICE
20 || resolve(root, "../build/bin", process.platform === "win32" ? "reasonix-desktop-service.exe" : "reasonix-desktop-service");
21 if (!existsSync(service)) {
22 console.error(`desktop service binary not found: ${service}`);
23 process.exit(2);
24 }
25 if (!existsSync(resolve(root, "dist/main.cjs"))) {
26 console.error("shell not built: run pnpm build first");
27 process.exit(2);
28 }
29
30 const home = mkdtempSync(join(tmpdir(), "reasonix-electron-smoke-"));
31 const checks = [];
32 const check = (name, ok, detail = "") => {
33 checks.push({ name, ok: Boolean(ok), detail });
34 console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` (${detail})` : ""}`);
35 };
36
37 async function mainRenderer(app) {
38 for (let attempt = 0; attempt < 20; attempt += 1) {
39 const page = app.windows().find((candidate) => candidate.url().startsWith("reasonix://app/"));
40 if (page) return page;
41 await new Promise((resolveWait) => setTimeout(resolveWait, 100));
42 }
43 throw new Error("Reasonix main renderer is unavailable");
44 }
45
46 function processTree(pid) {
47 if (process.platform === "win32") return [];
48 const rows = execFileSync("ps", ["-axo", "pid=,ppid=,comm="], { encoding: "utf8" }).split("\n");
49 const children = new Map();
50 for (const row of rows) {
51 const [p, pp, ...comm] = row.trim().split(/\s+/);
52 if (!p) continue;
53 const list = children.get(Number(pp)) ?? [];
54 list.push({ pid: Number(p), comm: comm.join(" ") });
55 children.set(Number(pp), list);
56 }
57 const out = [];
58 const walk = (parent) => {
59 for (const child of children.get(parent) ?? []) {
60 out.push(child);
61 walk(child.pid);
62 }
63 };
64 walk(pid);
65 return out;
66 }
67
68 function alive(pid) {
69 try {
70 process.kill(pid, 0);
71 return true;
72 } catch {
73 return false;
74 }
75 }
76
77 const started = Date.now();
78 const app = await electron.launch({
79 args: [root],
80 env: {
81 ...process.env,
82 REASONIX_HOME: home,
83 REASONIX_STATE_HOME: home,
84 REASONIX_CACHE_HOME: join(home, "cache"),
85 REASONIX_DEV: "1",
86 REASONIX_DESKTOP_SERVICE: service,
87 },
88 timeout: 60_000,
89 });
90 const shellPid = app.process().pid;
91 let exitCode = 1;
92 try {
93 let page = await app.firstWindow({ timeout: 60_000 });
94 await page.waitForURL("reasonix://app/index.html", { timeout: 60_000 });
95 await page.waitForFunction(() => Boolean(window.reasonixDesktop), null, { timeout: 30_000 });
96 const contract = await page.evaluate(() => ({
97 digest: window.reasonixDesktop.contract.digest,
98 commands: window.reasonixDesktop.contract.commands.length,
99 protocolVersion: window.reasonixDesktop.contract.protocolVersion,
100 }));
101 check("preload exposes the contract", contract.digest.startsWith("sha256:") && contract.commands > 500, `${contract.commands} commands, ${contract.digest.slice(0, 19)}`);
102
103 await page.waitForFunction(() => !document.querySelector(".boot-shell"), null, { timeout: 60_000 });
104 const readyMs = Date.now() - started;
105 check("React replaced the boot shell", true, `${readyMs} ms after launch`);
106
107 const state = await page.evaluate(() => new Promise((resolveState) => {
108 const off = window.reasonixDesktop.native.onServiceState((s) => {
109 if (s.phase === "ready" || s.phase === "failed" || s.phase === "exited") {
110 queueMicrotask(() => { off(); resolveState(s); });
111 }
112 });
113 setTimeout(() => resolveState({ phase: "timeout" }), 10_000);
114 }));
115 check("late subscribers receive the service state", state.phase === "ready", `phase=${state.phase} generation=${state.generation ?? ""}`);
116
117 const version = await page.evaluate(() => window.reasonixDesktop.invoke("Version", []));
118 const platform = await page.evaluate(() => window.reasonixDesktop.invoke("Platform", []));
119 check("desktop/invoke round-trips business commands", typeof version === "string" && typeof platform === "string", `Version=${version} Platform=${platform}`);
120
121 const unknown = await page.evaluate(() => window.reasonixDesktop.invoke("NoSuchCommand", []).then(() => "resolved", (e) => String(e.message)));
122 check("unknown commands are rejected before reaching Go", unknown.includes("-32601"), unknown);
123
124 const shellStatus = await page.evaluate(() => window.reasonixDesktop.invoke("GetDesktopShellStatus", []));
125 check("shell status is served by Go", shellStatus && typeof shellStatus === "object", JSON.stringify(shellStatus).slice(0, 120));
126
127 const bounds = await page.evaluate(() => window.reasonixDesktop.native.window.getBounds());
128 check("native window bounds are readable", bounds.width >= 760 && bounds.height >= 480, `${bounds.width}x${bounds.height} at ${bounds.x},${bounds.y}`);
129
130 const leaked = await page.evaluate(() => ({ go: typeof window.go, runtime: typeof window.runtime, require: typeof window.require, process: typeof window.process }));
131 check("renderer has no Wails or Node globals", Object.values(leaked).every((t) => t === "undefined"), JSON.stringify(leaked));
132
133 const errors = await page.evaluate(() => document.querySelector(".error-boundary, [data-crash-overlay]") !== null);
134 check("no crash overlay is showing", !errors);
135
136 let tab;
137 try {
138 tab = await page.evaluate(() => window.reasonixDesktop.browser.open("example.com", { temporary: true }));
139 } catch (error) {
140 // Electron can replace Playwright's execution context when the first
141 // WebContentsView is attached. The application renderer remains alive;
142 // reacquire it by URL and verify the native operation's committed result.
143 if (!String(error).includes("Execution context was destroyed")) throw error;
144 page = await mainRenderer(app);
145 tab = await page.evaluate(async () => {
146 const tabs = await window.reasonixDesktop.browser.list();
147 return tabs.find((entry) => {
148 try { return new URL(entry.url).origin === "https://example.com"; } catch { return false; }
149 });
150 });
151 }
152 check("browser opens a website view", Boolean(tab) && typeof tab.id === "string" && new URL(tab.url).origin === "https://example.com", tab ? `${tab.id} ${tab.url}` : "tab missing");
153 if (!tab) throw new Error("example.com browser tab was not created");
154 const title = await page.evaluate(async (tabId) => {
155 for (let attempt = 0; attempt < 50; attempt += 1) {
156 const tabs = await window.reasonixDesktop.browser.list();
157 const found = tabs.find((entry) => entry.id === tabId);
158 if (found && found.title !== "") return found.title;
159 await new Promise((resolveWait) => setTimeout(resolveWait, 200));
160 }
161 return "";
162 }, tab.id);
163 check("the website view loads example.com", title === "Example Domain", `title=${JSON.stringify(title)}`);
164 await page.evaluate((tabId) => window.reasonixDesktop.browser.close(tabId), tab.id);
165 const remaining = await page.evaluate(() => window.reasonixDesktop.browser.list());
166 check("browser tab closes cleanly", remaining.every((entry) => entry.id !== tab.id), `${remaining.length} tabs left`);
167
168 await page.screenshot({ path: join(artifacts, "main-window.png") });
169 const tree = processTree(shellPid);
170 const servicePids = tree.filter((p) => p.comm.includes("reasonix-desktop")).map((p) => p.pid);
171 check("Go service runs as a child of the shell", servicePids.length === 1, `pids=${servicePids.join(",")} tree=${tree.length}`);
172
173 await app.close();
174 await new Promise((r) => setTimeout(r, 1500));
175 check("shell exited", !alive(shellPid));
176 check("Go service exited with the shell", servicePids.every((pid) => !alive(pid)));
177 exitCode = checks.every((c) => c.ok) ? 0 : 1;
178 } catch (error) {
179 check("smoke run completed", false, String(error?.message ?? error));
180 try {
181 await app.close();
182 } catch {
183 // the shell may already be gone
184 }
185 } finally {
186 const shellLog = join(home, "desktop-shell", "logs", "shell.log");
187 if (existsSync(shellLog)) writeFileSync(join(artifacts, "shell.log"), readFileSync(shellLog));
188 const serviceLog = join(home, "desktop-shell", "logs", "service.log");
189 if (existsSync(serviceLog)) writeFileSync(join(artifacts, "service.log"), readFileSync(serviceLog));
190 writeFileSync(join(artifacts, "smoke.json"), JSON.stringify({ at: new Date().toISOString(), platform: process.platform, arch: process.arch, electron: require("electron/package.json").version, checks }, null, 2) + "\n");
191 rmSync(home, { recursive: true, force: true });
192 }
193 process.exit(exitCode);
194
194 lines Plain Text