返回 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 previewProject = join(home, "preview-project");
32 mkdirSync(previewProject, { recursive: true });
33 const previewFile = join(previewProject, "index.html");
34 const previewDocument = (version) => `<!doctype html><meta charset="utf-8"><title>Preview ${version}</title>
35 <button id="action">Run ${version}</button><output id="result">idle</output>
36 <script>document.querySelector('#action').onclick=()=>document.querySelector('#result').textContent='${version}'</script>`;
37 writeFileSync(previewFile, previewDocument("v1"));
38 const checks = [];
39 const check = (name, ok, detail = "") => {
40 checks.push({ name, ok: Boolean(ok), detail });
41 console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` (${detail})` : ""}`);
42 };
43
44 async function mainRenderer(app) {
45 for (let attempt = 0; attempt < 20; attempt += 1) {
46 const page = app.windows().find((candidate) => candidate.url().startsWith("reasonix://app/"));
47 if (page) return page;
48 await new Promise((resolveWait) => setTimeout(resolveWait, 100));
49 }
50 throw new Error("Reasonix main renderer is unavailable");
51 }
52
53 function processTree(pid) {
54 if (process.platform === "win32") return [];
55 const rows = execFileSync("ps", ["-axo", "pid=,ppid=,comm="], { encoding: "utf8" }).split("\n");
56 const children = new Map();
57 for (const row of rows) {
58 const [p, pp, ...comm] = row.trim().split(/\s+/);
59 if (!p) continue;
60 const list = children.get(Number(pp)) ?? [];
61 list.push({ pid: Number(p), comm: comm.join(" ") });
62 children.set(Number(pp), list);
63 }
64 const out = [];
65 const walk = (parent) => {
66 for (const child of children.get(parent) ?? []) {
67 out.push(child);
68 walk(child.pid);
69 }
70 };
71 walk(pid);
72 return out;
73 }
74
75 function alive(pid) {
76 try {
77 process.kill(pid, 0);
78 return true;
79 } catch {
80 return false;
81 }
82 }
83
84 const started = Date.now();
85 const app = await electron.launch({
86 args: [root],
87 env: {
88 ...process.env,
89 REASONIX_HOME: home,
90 REASONIX_STATE_HOME: home,
91 REASONIX_CACHE_HOME: join(home, "cache"),
92 REASONIX_DEV: "1",
93 REASONIX_DESKTOP_SERVICE: service,
94 },
95 timeout: 60_000,
96 });
97 const shellPid = app.process().pid;
98 let exitCode = 1;
99 try {
100 let page = await app.firstWindow({ timeout: 60_000 });
101 await page.waitForURL("reasonix://app/index.html", { timeout: 60_000 });
102 await page.waitForFunction(() => Boolean(window.reasonixDesktop), null, { timeout: 30_000 });
103 const contract = await page.evaluate(() => ({
104 digest: window.reasonixDesktop.contract.digest,
105 commands: window.reasonixDesktop.contract.commands.length,
106 protocolVersion: window.reasonixDesktop.contract.protocolVersion,
107 }));
108 check("preload exposes the contract", contract.digest.startsWith("sha256:") && contract.commands > 500, `${contract.commands} commands, ${contract.digest.slice(0, 19)}`);
109
110 await page.waitForFunction(() => !document.querySelector(".boot-shell"), null, { timeout: 60_000 });
111 const readyMs = Date.now() - started;
112 check("React replaced the boot shell", true, `${readyMs} ms after launch`);
113
114 const state = await page.evaluate(() => new Promise((resolveState) => {
115 const off = window.reasonixDesktop.native.onServiceState((s) => {
116 if (s.phase === "ready" || s.phase === "failed" || s.phase === "exited") {
117 queueMicrotask(() => { off(); resolveState(s); });
118 }
119 });
120 setTimeout(() => resolveState({ phase: "timeout" }), 10_000);
121 }));
122 check("late subscribers receive the service state", state.phase === "ready", `phase=${state.phase} generation=${state.generation ?? ""}`);
123
124 const version = await page.evaluate(() => window.reasonixDesktop.invoke("Version", []));
125 const platform = await page.evaluate(() => window.reasonixDesktop.invoke("Platform", []));
126 check("desktop/invoke round-trips business commands", typeof version === "string" && typeof platform === "string", `Version=${version} Platform=${platform}`);
127
128 const unknown = await page.evaluate(() => window.reasonixDesktop.invoke("NoSuchCommand", []).then(() => "resolved", (e) => String(e.message)));
129 check("unknown commands are rejected before reaching Go", unknown.includes("-32601"), unknown);
130
131 const shellStatus = await page.evaluate(() => window.reasonixDesktop.invoke("GetDesktopShellStatus", []));
132 check("shell status is served by Go", shellStatus && typeof shellStatus === "object", JSON.stringify(shellStatus).slice(0, 120));
133
134 const bounds = await page.evaluate(() => window.reasonixDesktop.native.window.getBounds());
135 check("native window bounds are readable", bounds.width >= 760 && bounds.height >= 480, `${bounds.width}x${bounds.height} at ${bounds.x},${bounds.y}`);
136
137 const leaked = await page.evaluate(() => ({ go: typeof window.go, runtime: typeof window.runtime, require: typeof window.require, process: typeof window.process }));
138 check("renderer has no Wails or Node globals", Object.values(leaked).every((t) => t === "undefined"), JSON.stringify(leaked));
139
140 const errors = await page.evaluate(() => document.querySelector(".error-boundary, [data-crash-overlay]") !== null);
141 check("no crash overlay is showing", !errors);
142
143 let tab;
144 try {
145 tab = await page.evaluate(() => window.reasonixDesktop.browser.open("example.com", { temporary: true }));
146 } catch (error) {
147 // Electron can replace Playwright's execution context when the first
148 // WebContentsView is attached. The application renderer remains alive;
149 // reacquire it by URL and verify the native operation's committed result.
150 if (!String(error).includes("Execution context was destroyed")) throw error;
151 page = await mainRenderer(app);
152 tab = await page.evaluate(async () => {
153 const tabs = await window.reasonixDesktop.browser.list();
154 return tabs.find((entry) => {
155 try { return new URL(entry.url).origin === "https://example.com"; } catch { return false; }
156 });
157 });
158 }
159 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");
160 if (!tab) throw new Error("example.com browser tab was not created");
161 const title = await page.evaluate(async (tabId) => {
162 for (let attempt = 0; attempt < 50; attempt += 1) {
163 const tabs = await window.reasonixDesktop.browser.list();
164 const found = tabs.find((entry) => entry.id === tabId);
165 if (found && found.title !== "") return found.title;
166 await new Promise((resolveWait) => setTimeout(resolveWait, 200));
167 }
168 return "";
169 }, tab.id);
170 check("the website view loads example.com", title === "Example Domain", `title=${JSON.stringify(title)}`);
171 await page.evaluate((tabId) => window.reasonixDesktop.browser.close(tabId), tab.id);
172 const remaining = await page.evaluate(() => window.reasonixDesktop.browser.list());
173 check("browser tab closes cleanly", remaining.every((entry) => entry.id !== tab.id), `${remaining.length} tabs left`);
174
175 await app.evaluate(({ shell }) => {
176 globalThis.__reasonixSmokeOpenExternal = { calls: [], original: shell.openExternal };
177 shell.openExternal = async (url) => { globalThis.__reasonixSmokeOpenExternal.calls.push(url); };
178 });
179 const projectTab = await page.evaluate((rootPath) => window.reasonixDesktop.invoke("EnsureBlankTab", ["project", rootPath]), previewProject);
180 const modelRejection = await page.evaluate(async (tabId) => window.reasonixDesktop.invokeResult("StartTurnWithModelApplication", [tabId, "native-stale-model-choice", { input: "do not execute", display: "do not execute", invocations: [], attachments: [] }, { mode: "applied_once", expectedAppliedRevision: "stale", expectedDesiredRevision: "stale", expectedRuntimeIdentity: "stale" }]), projectTab.id);
181 check("native context bridge preserves definite rejection and model recovery data", modelRejection.ok === false && modelRejection.data?.submissionOutcome === "not_accepted" && modelRejection.data?.modelApplication?.code === "model_choice_stale", JSON.stringify(modelRejection).slice(0, 240));
182 const openPreview = async (operationId) => {
183 try {
184 return await page.evaluate(({ tabId, generation, operation }) => window.reasonixDesktop.invoke("OpenFileBrowserPreviewForTab", [tabId, {
185 source: "workspace", path: "index.html", operationId: operation,
186 expectedSessionGeneration: generation, userInitiated: true,
187 }]), { tabId: projectTab.id, generation: projectTab.sessionGeneration, operation: operationId });
188 } catch (error) {
189 if (!String(error).includes("Execution context was destroyed")) throw error;
190 page = await mainRenderer(app);
191 const owned = await page.evaluate((taskId) => window.reasonixDesktop.browser.list().then((tabs) => tabs.find((entry) => entry.taskId === taskId)), projectTab.id);
192 if (!owned) throw error;
193 return { tabId: owned.id, url: owned.url, status: "opened", sessionGeneration: projectTab.sessionGeneration ?? 0 };
194 }
195 };
196 const executeInPreview = async (url, expression) => app.evaluate(async ({ webContents }, input) => {
197 for (let attempt = 0; attempt < 50; attempt += 1) {
198 const target = webContents.getAllWebContents().find((contents) => contents.getURL() === input.url);
199 if (target && !target.isLoading()) return target.executeJavaScript(input.expression, true);
200 await new Promise((resolveWait) => setTimeout(resolveWait, 100));
201 }
202 throw new Error(`preview WebContents did not load ${input.url}`);
203 }, { url, expression });
204
205 const previewV1 = await openPreview("smoke-preview-v1");
206 const firstInteraction = await executeInPreview(previewV1.url, "document.querySelector('#action').click(); document.querySelector('#result').textContent");
207 check("workspace HTML runs in the built-in browser", ["opened", "loading"].includes(previewV1.status) && firstInteraction === "v1", `${previewV1.tabId} ${firstInteraction}`);
208
209 writeFileSync(previewFile, previewDocument("v2"));
210 const previewV2 = await openPreview("smoke-preview-v2");
211 const secondInteraction = await executeInPreview(previewV2.url, "document.querySelector('#action').click(); document.querySelector('#result').textContent");
212 check("updated HTML reuses and reloads the preview tab", previewV2.tabId === previewV1.tabId && previewV2.url !== previewV1.url && secondInteraction === "v2", `${previewV2.tabId} ${secondInteraction}`);
213 const previewTabs = await page.evaluate((taskId) => window.reasonixDesktop.browser.list().then((tabs) => tabs.filter((entry) => entry.taskId === taskId)), projectTab.id);
214 check("file preview keeps one task-owned browser tab", previewTabs.length === 1 && previewTabs[0].id === previewV1.tabId, `${previewTabs.length} tab(s)`);
215 const externalOpens = await app.evaluate(({ shell }) => {
216 const state = globalThis.__reasonixSmokeOpenExternal;
217 shell.openExternal = state.original;
218 return state.calls;
219 });
220 check("HTML preview never invokes the external browser", externalOpens.length === 0, `${externalOpens.length} calls`);
221 await page.evaluate((tabId) => window.reasonixDesktop.browser.close(tabId), previewV1.tabId);
222
223 await page.screenshot({ path: join(artifacts, "main-window.png") });
224 const tree = processTree(shellPid);
225 const servicePids = tree.filter((p) => p.comm.includes("reasonix-desktop")).map((p) => p.pid);
226 check("Go service runs as a child of the shell", servicePids.length === 1, `pids=${servicePids.join(",")} tree=${tree.length}`);
227
228 await app.close();
229 await new Promise((r) => setTimeout(r, 1500));
230 check("shell exited", !alive(shellPid));
231 check("Go service exited with the shell", servicePids.every((pid) => !alive(pid)));
232 exitCode = checks.every((c) => c.ok) ? 0 : 1;
233 } catch (error) {
234 check("smoke run completed", false, String(error?.message ?? error));
235 try {
236 await app.close();
237 } catch {
238 // the shell may already be gone
239 }
240 } finally {
241 const shellLog = join(home, "desktop-shell", "logs", "shell.log");
242 if (existsSync(shellLog)) writeFileSync(join(artifacts, "shell.log"), readFileSync(shellLog));
243 const serviceLog = join(home, "desktop-shell", "logs", "service.log");
244 if (existsSync(serviceLog)) writeFileSync(join(artifacts, "service.log"), readFileSync(serviceLog));
245 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");
246 rmSync(home, { recursive: true, force: true });
247 }
248 process.exit(exitCode);
249
249 lines Plain Text