| 1 | // Real Electron shell + full App + current Go service, with a disposable home. |
| 2 | // Build first: cd desktop && go build -o build/bin/reasonix-desktop-service . |
| 3 | // Then: cd electron && pnpm build |
| 4 | // Also build the renderer: cd desktop/frontend && pnpm build |
| 5 | // Run: node desktop/packaging/independent-session-native-smoke.mjs [service] [evidence] |
| 6 | import assert from "node:assert/strict"; |
| 7 | import { createRequire } from "node:module"; |
| 8 | import { createServer as createHTTPServer } from "node:http"; |
| 9 | import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; |
| 10 | import { join, resolve, dirname } from "node:path"; |
| 11 | import { fileURLToPath } from "node:url"; |
| 12 | import { tmpdir } from "node:os"; |
| 13 | import { packagedSmokeEnv } from "./smoke-env.mjs"; |
| 14 | import { waitForSmokeCondition } from "./smoke-poll.mjs"; |
| 15 | |
| 16 | const desktop = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 17 | const requireShell = createRequire(join(desktop, "electron/package.json")); |
| 18 | const requireFrontend = createRequire(join(desktop, "frontend/package.json")); |
| 19 | const { _electron } = requireShell("playwright"); |
| 20 | const { preview } = await import(requireFrontend.resolve("vite")); |
| 21 | const service = resolve(process.argv[2] || join(desktop, "build/bin/reasonix-desktop-service")); |
| 22 | const evidence = resolve(process.argv[3] || join(tmpdir(), "reasonix-independent-native")); |
| 23 | const home = mkdtempSync(join(tmpdir(), "reasonix-independent-native-home-")); |
| 24 | mkdirSync(evidence, { recursive: true }); |
| 25 | const checks = []; |
| 26 | const errors = []; |
| 27 | const violations = []; |
| 28 | let releaseBackground; |
| 29 | const record = check => { checks.push(check); console.log(`PASS ${check}`); }; |
| 30 | const provider = createHTTPServer(async (req, res) => { |
| 31 | let raw = ""; |
| 32 | for await (const chunk of req) raw += chunk; |
| 33 | const messages = JSON.stringify(JSON.parse(raw || "{}").messages || []); |
| 34 | const content = messages.includes("BACKGROUND_CHILD") ? "ANSWER_BACKGROUND_CHILD" |
| 35 | : messages.includes("CHILD_ONLY") ? "ANSWER_CHILD_ONLY" : "ANSWER_PARENT_RETAINED"; |
| 36 | if (messages.includes("BACKGROUND_CHILD")) await new Promise(resolve => { releaseBackground = resolve; }); |
| 37 | res.writeHead(200, { "Content-Type": "text/event-stream" }); |
| 38 | res.end(`data: ${JSON.stringify({ id: "independent-fixture", choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n` |
| 39 | + `data: ${JSON.stringify({ id: "independent-fixture", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`); |
| 40 | }); |
| 41 | await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve)); |
| 42 | writeFileSync(join(home, "config.toml"), `default_model = "fixture/model"\n[desktop]\nprovider_access = ["fixture"]\n[[providers]]\nname = "fixture"\nkind = "openai"\nbase_url = "http://127.0.0.1:${provider.address().port}/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "INDEPENDENT_FIXTURE_KEY"\n`); |
| 43 | // Qualify the finished renderer build. A cold dev server transforms thousands |
| 44 | // of modules during shell startup and is not the shipped renderer artifact. |
| 45 | assert.ok(existsSync(join(desktop, "frontend/dist/index.html")), "build the renderer before native qualification"); |
| 46 | const vite = await preview({ root: join(desktop, "frontend"), logLevel: "error", preview: { host: "127.0.0.1", port: 0 } }); |
| 47 | let application, page; |
| 48 | const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args }); |
| 49 | const active = async () => { |
| 50 | const tabs = await invoke("ListTabs"); |
| 51 | return tabs.find(tab => tab.active) ?? (tabs.length === 1 ? tabs[0] : undefined); |
| 52 | }; |
| 53 | async function capturePhase(phase) { |
| 54 | const snapshot = { tabs: await invoke("ListTabs"), workspace: await invoke("GetWorkspaceSnapshot") }; |
| 55 | writeFileSync(join(evidence, `phase-${phase}.json`), JSON.stringify(snapshot, null, 2)); |
| 56 | return snapshot; |
| 57 | } |
| 58 | async function list() { |
| 59 | // Catalog hydration can invalidate a snapshot during startup; the contract |
| 60 | // requires a fresh first-page read, never appending to the rejected page. |
| 61 | for (let attempt = 0; ; attempt++) { |
| 62 | try { return await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }]); } |
| 63 | catch (error) { if (attempt >= 2 || !String(error).includes("stale_cursor")) throw error; } |
| 64 | } |
| 65 | } |
| 66 | const row = title => page.locator(".project-tree__topic-main").filter({ has: page.getByText(title, { exact: true }) }); |
| 67 | const settle = () => waitForSmokeCondition(async () => (await invoke("ListTabs")).every(tab => !tab.running)); |
| 68 | async function launch() { |
| 69 | application = await _electron.launch({ args: [join(desktop, "electron")], env: { |
| 70 | ...packagedSmokeEnv(process.env, home), REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: service, |
| 71 | REASONIX_ELECTRON_DEV_URL: `http://127.0.0.1:${vite.httpServer.address().port}`, |
| 72 | INDEPENDENT_FIXTURE_KEY: "loopback-only", |
| 73 | }, timeout: 60_000 }); |
| 74 | page = await application.firstWindow(); |
| 75 | page.setDefaultTimeout(30000); |
| 76 | page.on("pageerror", error => errors.push(error.message)); |
| 77 | await page.waitForFunction(() => Boolean(window.reasonixDesktop)); |
| 78 | await page.evaluate(() => new Promise((resolve, reject) => { |
| 79 | const timeout = setTimeout(() => reject(new Error("native service readiness timed out")), 30000); |
| 80 | const off = window.reasonixDesktop.native.onServiceState(state => { |
| 81 | if (state.phase === "ready") queueMicrotask(() => { clearTimeout(timeout); off(); resolve(); }); |
| 82 | else if (["failed", "exited"].includes(state.phase)) queueMicrotask(() => { clearTimeout(timeout); off(); reject(new Error(JSON.stringify(state))); }); |
| 83 | }); |
| 84 | })); |
| 85 | // Absence of the boot placeholder is also true before React mounts. Wait |
| 86 | // for the actual interactive surface before issuing a user navigation; |
| 87 | // otherwise the fixture races initial tab restoration with EnsureBlank. |
| 88 | await page.locator("textarea").first().waitFor({ state: "visible" }); |
| 89 | } |
| 90 | async function close() { await application.close(); application = null; } |
| 91 | async function send(text, response) { |
| 92 | const composer = page.locator("textarea").first(); |
| 93 | await composer.fill(text); |
| 94 | await page.locator(".composer__btn--send").click(); |
| 95 | await page.waitForFunction(text => document.querySelector(".chat-transcript")?.textContent?.includes(text), response); |
| 96 | await waitForSmokeCondition(async () => Boolean((await active())?.session?.sessionId)); |
| 97 | await settle(); |
| 98 | } |
| 99 | async function select(title, ref) { |
| 100 | await row(title).click(); |
| 101 | await waitForSmokeCondition(async () => (await active())?.session?.sessionId === ref.sessionId); |
| 102 | await page.waitForFunction(() => document.querySelector(".transcript-navigation-surface")?.getAttribute("aria-busy") === "false" |
| 103 | && Boolean(document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_PARENT_RETAINED"))); |
| 104 | await page.waitForTimeout(500); // Observe post-hydration title rather than the optimistically selected shell. |
| 105 | const visibleTitle = await page.locator(".topicbar h1").innerText(); |
| 106 | if (visibleTitle !== title) violations.push({ check: "active title follows saved session presentation", expected: title, actual: visibleTitle }); |
| 107 | const activeRows = await page.locator(".project-tree__topic--active").count(); |
| 108 | if (activeRows !== 1) violations.push({ check: "exactly one selected sidebar row", expected: 1, actual: activeRows, sessionId: ref.sessionId }); |
| 109 | assert.equal(await page.locator(".project-tree__topic-main").count(), 2, "opening one session must not add a third runtime projection"); |
| 110 | } |
| 111 | try { |
| 112 | await launch(); |
| 113 | const initialBlank = await capturePhase("initial"); |
| 114 | const version = await invoke("Version"); |
| 115 | await page.locator(".sidebar__quick-action").click(); |
| 116 | await page.locator(".session-draft-surface").waitFor({ state: "visible" }); |
| 117 | const createdBlank = await capturePhase("created-blank"); |
| 118 | assert.equal(initialBlank.tabs.length, 0, "fresh home starts without a durable session"); |
| 119 | assert.equal(createdBlank.tabs.length, 0, "blank draft does not create a canonical session before first send"); |
| 120 | assert.equal(createdBlank.workspace.pendingCreates.length, 0, "blank draft leaves no abandoned pending create"); |
| 121 | assert.equal(createdBlank.workspace.workspaces.flatMap(workspace => workspace.sessionIds).length, 0, |
| 122 | "blank draft leaves no abandoned recovery row"); |
| 123 | record("blank startup stays a durable draft without abandoned canonical or pending rows"); |
| 124 | await send("PARENT_RETAINED", "ANSWER_PARENT_RETAINED"); |
| 125 | await capturePhase("sent"); |
| 126 | const parent = (await active()).session; |
| 127 | await invoke("RenameSessionTarget", [{ ref: parent }, "Native parent A"]); |
| 128 | const targets = await invoke("ForkTargetsForTab", [(await active()).id]); |
| 129 | const boundary = targets.targets.find(target => target.available); |
| 130 | assert.ok(boundary, "completed real provider turn exposes a verifiable fork boundary"); |
| 131 | const child = await invoke("ForkSessionTarget", [{ ref: parent }, boundary.turnId]); |
| 132 | assert.notEqual(parent.sessionId, child.sessionId); |
| 133 | await invoke("RenameSessionTarget", [{ ref: child }, "Native child B"]); |
| 134 | const forked = await list(); |
| 135 | const sourceTopic = forked.items.find(node => node.session?.sessionId === parent.sessionId).topicId; |
| 136 | await capturePhase("forked"); |
| 137 | // Seed upgrade-era shared-topic presentation only after the isolated owner stops. |
| 138 | // Current forks may receive distinct topic metadata; transcript and SessionIDs |
| 139 | // remain untouched so the reopened real service exercises existing A/B history. |
| 140 | await close(); |
| 141 | const registryPath = join(home, "desktop/workspace-state-v1.json"); |
| 142 | const registry = JSON.parse(readFileSync(registryPath, "utf8")); |
| 143 | registry.presentation[child.sessionId].topicId = sourceTopic; |
| 144 | writeFileSync(registryPath, JSON.stringify(registry)); |
| 145 | await launch(); |
| 146 | const initial = await list(); |
| 147 | const a = initial.items.find(node => node.session?.sessionId === parent.sessionId); |
| 148 | const b = initial.items.find(node => node.session?.sessionId === child.sessionId); |
| 149 | assert.ok(a && b); |
| 150 | assert.equal(a.topicId, b.topicId, "fixture must exercise the same TopicID"); |
| 151 | await row("Native parent A").waitFor(); |
| 152 | await row("Native child B").waitFor(); |
| 153 | assert.equal(await page.locator(".project-tree__topic-main").count(), 2, "directory and runtime coalesce into the same two logical rows"); |
| 154 | assert.equal((await row("Native parent A").boundingBox()).x, (await row("Native child B").boundingBox()).x); |
| 155 | record("real fork with shared TopicID is displayed as two sibling rows"); |
| 156 | await select("Native child B", child); |
| 157 | await page.locator(".topicbar__title-button").click(); |
| 158 | await page.locator(".topicbar__title-input").fill("Native child renamed B"); |
| 159 | await page.locator(".topicbar__title-input").press("Enter"); |
| 160 | await row("Native child renamed B").waitFor(); |
| 161 | assert.equal((await list()).items.find(node => node.session?.sessionId === parent.sessionId).label, "Native parent A"); |
| 162 | await send("CHILD_ONLY", "ANSWER_CHILD_ONLY"); |
| 163 | assert.ok(!JSON.stringify(await invoke("ReadSessionHistory", [parent, "", 32])).includes("CHILD_ONLY")); |
| 164 | record("top rename and further child turn leave parent title/history intact"); |
| 165 | await page.locator("textarea").first().fill("BACKGROUND_CHILD"); |
| 166 | await page.locator(".composer__btn--send").click(); |
| 167 | await waitForSmokeCondition(async () => Boolean(releaseBackground) && Boolean((await active())?.running)); |
| 168 | await select("Native parent A", parent); |
| 169 | releaseBackground(); |
| 170 | await waitForSmokeCondition(async () => JSON.stringify(await invoke("ReadSessionHistory", [child, "", 32])).includes("ANSWER_BACKGROUND_CHILD")); |
| 171 | assert.equal((await active()).session.sessionId, parent.sessionId); |
| 172 | assert.ok(!JSON.stringify(await invoke("ReadSessionHistory", [parent, "", 32])).includes("BACKGROUND_CHILD")); |
| 173 | record("switching away lets B complete in background without changing A runtime or history"); |
| 174 | for (const [title, ref] of [["Native parent A", parent], ["Native child renamed B", child], ["Native parent A", parent]]) await select(title, ref); |
| 175 | record("A/B/A sidebar navigation resolves the exact native runtime"); |
| 176 | await row("Native child renamed B").click({ button: "right" }); |
| 177 | await page.getByRole("menuitem", { name: /Move to trash|移至回收站|移到回收站|移至垃圾桶/ }).click(); |
| 178 | await page.getByRole("menuitem", { name: /Confirm|确认|確認/ }).click(); |
| 179 | await waitForSmokeCondition(async () => (await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(child.sessionId)); |
| 180 | await row("Native child renamed B").waitFor({ state: "hidden" }); |
| 181 | await row("Native parent A").waitFor(); |
| 182 | await page.screenshot({ path: join(evidence, "archived-child.png") }); |
| 183 | await close(); |
| 184 | await launch(); |
| 185 | assert.ok((await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(child.sessionId)); |
| 186 | assert.ok(!(await list()).items.some(node => node.session?.sessionId === child.sessionId)); |
| 187 | assert.ok((await list()).items.some(node => node.session?.sessionId === parent.sessionId)); |
| 188 | record("native menu archives only B and restart preserves the lifecycle"); |
| 189 | await invoke("RestoreSessionTarget", [{ ref: child }]); |
| 190 | await row("Native child renamed B").waitFor(); |
| 191 | await select("Native child renamed B", child); |
| 192 | await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_CHILD_ONLY")); |
| 193 | await close(); |
| 194 | await launch(); |
| 195 | await select("Native child renamed B", child); |
| 196 | await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_CHILD_ONLY")); |
| 197 | record("restore retains child SessionID and transcript across another native restart"); |
| 198 | assert.deepEqual(errors, []); |
| 199 | assert.deepEqual(violations, [], "all observed native invariants must hold"); |
| 200 | await page.screenshot({ path: join(evidence, "restored-child.png") }); |
| 201 | writeFileSync(join(evidence, "result.json"), JSON.stringify({ passed: true, version, parent, child, checks, errors, |
| 202 | scope: "real Electron development shell, built production App renderer, real Go service, loopback provider" }, null, 2)); |
| 203 | } catch (error) { |
| 204 | writeFileSync(join(evidence, "result.json"), JSON.stringify({ passed: false, checks, errors, violations, error: String(error.stack) }, null, 2)); |
| 205 | if (page && !page.isClosed()) { |
| 206 | await page.screenshot({ path: join(evidence, "failure.png") }).catch(() => {}); |
| 207 | writeFileSync(join(evidence, "failure-state.json"), JSON.stringify({ |
| 208 | body: await page.locator("body").innerText().catch(() => ""), tabs: await invoke("ListTabs").catch(() => null), |
| 209 | topics: await list().catch(() => null), workspace: await invoke("GetWorkspaceSnapshot").catch(() => null), |
| 210 | }, null, 2)); |
| 211 | } |
| 212 | throw error; |
| 213 | } finally { |
| 214 | releaseBackground?.(); |
| 215 | if (application) await close(); |
| 216 | for (const name of ["shell.log", "service.log"]) { |
| 217 | const source = join(home, "desktop-shell/logs", name); |
| 218 | if (existsSync(source)) writeFileSync(join(evidence, name), readFileSync(source)); |
| 219 | } |
| 220 | await vite.close(); |
| 221 | provider.closeAllConnections(); |
| 222 | await new Promise(resolve => provider.close(resolve)); |
| 223 | rmSync(home, { recursive: true, force: true }); |
| 224 | } |
| 225 |