| 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 |