| 1 | // Packaged application only: no service override or development handshake bypass. |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import path from "node:path"; |
| 6 | import { _electron as electron } from "playwright"; |
| 7 | import { once } from "node:events"; |
| 8 | import { execFileSync } from "node:child_process"; |
| 9 | |
| 10 | const executablePath = process.argv[2]; |
| 11 | if (!executablePath) throw new Error("usage: node manual-session-smoke.mjs <packaged executable>"); |
| 12 | const home = mkdtempSync(path.join(tmpdir(), "reasonix-manual-package-")); |
| 13 | const evidence = process.env.REASONIX_MANUAL_EVIDENCE || "/tmp/reasonix-manual-package-evidence"; |
| 14 | mkdirSync(evidence, { recursive: true }); |
| 15 | const env = { ...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: path.join(home,"cache") }; |
| 16 | delete env.REASONIX_DEV; |
| 17 | delete env.REASONIX_DESKTOP_SERVICE; |
| 18 | let shell; |
| 19 | const results = []; |
| 20 | async function launch() { |
| 21 | shell = await electron.launch({ executablePath, env, timeout: 60000 }); |
| 22 | const page = await shell.firstWindow(); |
| 23 | await page.waitForURL("reasonix://app/index.html", { timeout: 60000 }); |
| 24 | await page.waitForFunction(() => !!window.reasonixDesktop, null, { timeout: 30000 }); |
| 25 | await page.waitForFunction(() => !document.querySelector(".boot-shell"), null, { timeout: 60000 }); |
| 26 | await page.locator(".app").waitFor({state:"visible",timeout:60000}); |
| 27 | // An isolated first launch opens provider setup. Leave it through its normal |
| 28 | // Back control; no credentials or production configuration are installed. |
| 29 | await page.addLocatorHandler(page.locator(".management-screen__back:visible"),async button=>{await button.click();}); |
| 30 | // The React splash is distinct from index.html's boot shell. Actionability |
| 31 | // proves that an actual user can reach the welcome surface underneath it. |
| 32 | await page.locator(".sidebar__quick-action:visible").first().click({trial:true,timeout:60000}); |
| 33 | return page; |
| 34 | } |
| 35 | const rpc = (page, command, ...args) => page.evaluate(({command,args}) => window.reasonixDesktop.invoke(command,args),{command,args}); |
| 36 | try { |
| 37 | let page = await launch(); |
| 38 | results.push({version:await rpc(page,"Version"),platform:await rpc(page,"Platform")}); |
| 39 | const operations = []; |
| 40 | for (let index=0;index<3;index++) operations.push(await rpc(page,"BeginManualSessionCreation",{operationId:`packaged-manual-${index}`,workspaceId:"",scope:"global"})); |
| 41 | for (const operation of operations) { |
| 42 | const deadline=Date.now()+60000; |
| 43 | let current=await rpc(page,"GetManualSessionCreation",operation.operationId); |
| 44 | while((current.phase==="reserved" || current.phase==="starting") && Date.now()<deadline) { |
| 45 | await new Promise(resolve=>setTimeout(resolve,100)); |
| 46 | current=await rpc(page,"GetManualSessionCreation",operation.operationId); |
| 47 | } |
| 48 | assert.equal(current.phase,"ready",JSON.stringify(current)); |
| 49 | assert.deepEqual((await rpc(page,"BeginManualSessionCreation",{operationId:operation.operationId,workspaceId:"",scope:"global"})).ref,operation.ref); |
| 50 | } |
| 51 | assert.equal(new Set(operations.map(op=>op.ref.sessionId)).size,3); |
| 52 | assert.deepEqual(await rpc(page,"ListSessionDraftSummaries"),[]); |
| 53 | const ref=operations[0].ref; |
| 54 | const state=await rpc(page,"GetSessionComposerState",ref); |
| 55 | const image="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQ0AAAAASUVORK5CYII="; |
| 56 | const attachment=await rpc(page,"SavePastedImageForComposerTarget",{kind:"session",session:ref},image); |
| 57 | const contentJson=JSON.stringify({text:"packaged unsent input 中文",attachments:[{path:attachment,displayName:"recovered.png"}],pastedBlocks:[],workspaceRefs:[],sessionRefs:[],selectedTextRefs:[],invocations:[],goalDraft:true}); |
| 58 | await rpc(page,"SaveSessionComposerState",{ref,expectedRevision:state.revision,contentVersion:1,contentJson}); |
| 59 | await page.screenshot({path:path.join(evidence,"created.png")}); |
| 60 | await shell.close(); shell=undefined; |
| 61 | page=await launch(); |
| 62 | assert.equal((await rpc(page,"GetSessionComposerState",ref)).contentJson,contentJson); |
| 63 | assert.match(await rpc(page,"AttachmentDataURLForComposerTarget",{kind:"session",session:ref},attachment),/^data:image\/png;base64,/); |
| 64 | const shellProcess=shell.process(); |
| 65 | const services=execFileSync("ps",["-axo","pid=,ppid=,comm="],{encoding:"utf8"}).split("\n").map(line=>line.trim().split(/\s+/)).filter(parts=>Number(parts[1])===shellProcess.pid && parts.slice(2).join(" ").includes("/Resources/service/reasonix-desktop")).map(parts=>Number(parts[0])); |
| 66 | assert.equal(services.length,1,"identify this isolated application's service before the crash"); |
| 67 | const exited=once(shellProcess,"exit"); |
| 68 | shellProcess.kill("SIGKILL"); await exited; shell=undefined; |
| 69 | const alive=pid=>{try {process.kill(pid,0);return true;} catch {return false;}}; |
| 70 | const deadline=Date.now()+30000; |
| 71 | while(services.some(alive) && Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,100)); |
| 72 | assert.equal(services.some(alive),false,"orphan service must release its leases after shell crash"); |
| 73 | page=await launch(); |
| 74 | assert.equal((await rpc(page,"GetSessionComposerState",ref)).contentJson,contentJson,"crash recovers acknowledged input"); |
| 75 | for (const op of operations) await rpc(page,"ArchiveSessionTarget",{ref:op.ref}); |
| 76 | await shell.close(); shell=undefined; |
| 77 | page=await launch(); |
| 78 | assert.equal((await rpc(page,"GetSessionComposerState",ref)).contentJson,contentJson); |
| 79 | assert.deepEqual(await rpc(page,"ListSessionDraftSummaries"),[]); |
| 80 | const snapshot=await rpc(page,"GetWorkspaceSnapshot"); |
| 81 | const identities=snapshot.workspaces.flatMap(workspace=>workspace.sessionIds); |
| 82 | assert.deepEqual([...new Set(identities)].sort(),operations.map(op=>op.ref.sessionId).sort(),"restart must not create any replacement identity"); |
| 83 | assert.equal(identities.filter(id=>!snapshot.archivedSessionIds.includes(id)).length,0); |
| 84 | assert.equal(await page.locator("textarea.composer__input:not([aria-hidden=true])").count(),0,"empty welcome must not expose an implicit input"); |
| 85 | await page.locator(".session-loading-indicator").waitFor({state:"hidden",timeout:10000}); |
| 86 | await page.screenshot({path:path.join(evidence,"archived-empty.png")}); |
| 87 | await shell.close(); shell=undefined; |
| 88 | results.push({passed:["matched shell/service handshake","three independent sessions and retry identities","normal exit and SQLite reopen","acknowledged input survives shell crash","image reference and preview survive restart","input survives archive and restart","last archive creates no replacement"]}); |
| 89 | writeFileSync(path.join(evidence,"results.json"),JSON.stringify({home,executablePath,results},null,2)); |
| 90 | console.log(`PASS packaged creation, persistence, archive and restart: ${evidence}`); |
| 91 | } catch (error) { |
| 92 | if (shell) { |
| 93 | const page=await shell.firstWindow(); |
| 94 | await page.screenshot({path:path.join(evidence,"failure.png")}); |
| 95 | writeFileSync(path.join(evidence,"failure.txt"),await page.locator("body").innerText()); |
| 96 | } |
| 97 | throw error; |
| 98 | } finally { if(shell) await shell.close(); } |
| 99 |