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