| 1 | // Development Electron shell + real Go service, isolated state, no provider. |
| 2 | // Native dialog responses are controlled; renderer actions and persistence are real. |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import { resolve, join } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { _electron } from "playwright"; |
| 9 | import { setTimeout as delay } from "node:timers/promises"; |
| 10 | |
| 11 | const root = resolve(fileURLToPath(new URL("..", import.meta.url))); |
| 12 | const home = mkdtempSync(join(tmpdir(), "reasonix-topic-removal-")); |
| 13 | const workspace = join(home, "workspace"); |
| 14 | mkdirSync(workspace); |
| 15 | const report = { home, mode: "development shell and service; controlled native confirmation", checks: [], errors: [] }; |
| 16 | const check = name => { report.checks.push(name); console.log(`PASS ${name}`); }; |
| 17 | let shell, page; |
| 18 | const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args }); |
| 19 | async function poll(read, matches) { |
| 20 | const deadline = Date.now() + 60000; |
| 21 | for (;;) { |
| 22 | const value = await read(); |
| 23 | if (matches(value)) return value; |
| 24 | if (Date.now() >= deadline) throw new Error("Durable RPC condition timed out"); |
| 25 | await delay(100); |
| 26 | } |
| 27 | } |
| 28 | async function launch() { |
| 29 | shell = await _electron.launch({ args: [root], env: { ...process.env, |
| 30 | REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home, "cache"), |
| 31 | REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: resolve(root, "../build/bin/reasonix-desktop-service"), |
| 32 | }, timeout: 60000 }); |
| 33 | page = await shell.firstWindow(); |
| 34 | page.setDefaultTimeout(30000); |
| 35 | // A fresh isolated profile may open provider setup. Return through its real |
| 36 | // Back button before operating the draft behind it; no provider is needed. |
| 37 | await page.addLocatorHandler(page.locator(".settings-screen[data-app-overlay] .management-screen__back"), async back => { await back.click(); }); |
| 38 | page.on("pageerror", error => report.errors.push(error.message)); |
| 39 | await page.waitForFunction(() => Boolean(window.reasonixDesktop) && !document.querySelector(".boot-shell"), null, { timeout: 60000 }); |
| 40 | await shell.evaluate(({ dialog }) => { |
| 41 | globalThis.__topicRemovalDialogs = []; |
| 42 | globalThis.__topicRemovalAccept = false; |
| 43 | dialog.showMessageBox = async (...args) => { |
| 44 | const options = args.at(-1); |
| 45 | globalThis.__topicRemovalDialogs.push(options); |
| 46 | return { response: globalThis.__topicRemovalAccept ? options.buttons.findIndex((_, index) => index !== options.cancelId) : options.cancelId, checkboxChecked: false }; |
| 47 | }; |
| 48 | }); |
| 49 | const deadline = Date.now() + 60000; |
| 50 | for (;;) { |
| 51 | try { await invoke("Version"); break; } |
| 52 | catch (error) { if (Date.now() >= deadline) throw error; await delay(100); } |
| 53 | } |
| 54 | console.log("Native service ready"); |
| 55 | } |
| 56 | async function openDraft(scope, workspaceRoot) { |
| 57 | const draft = await invoke("OpenSessionDraftForTarget", [scope, workspaceRoot]); |
| 58 | await invoke("SetSessionDraftRestoreTarget", [draft.id]); |
| 59 | await page.reload(); |
| 60 | await page.locator(".draft-topicbar-actions button").waitFor(); |
| 61 | return draft; |
| 62 | } |
| 63 | async function waitDiscarded(id) { |
| 64 | await poll(() => invoke("GetSessionDraft", [id]), draft => draft.status === "discarded"); |
| 65 | } |
| 66 | try { |
| 67 | await launch(); |
| 68 | await invoke("Version"); |
| 69 | const tabsBefore = await invoke("ListTabs"); |
| 70 | await page.locator(".sidebar__quick-action").click(); |
| 71 | await page.locator(".draft-topicbar-actions button").waitFor(); |
| 72 | const empty = await invoke("RestoreSessionDraft"); |
| 73 | assert.ok(empty?.id, "global quick-create must persist a draft identity"); |
| 74 | await page.locator(".draft-topicbar-actions button").click(); |
| 75 | await waitDiscarded(empty.id); |
| 76 | assert.equal(await shell.evaluate(() => globalThis.__topicRemovalDialogs.length), 0); |
| 77 | check("empty draft discarded through real renderer without confirmation"); |
| 78 | |
| 79 | const draft = await openDraft("project", workspace); |
| 80 | const composer = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 81 | await composer.fill("Keep this draft until confirmed"); |
| 82 | await page.evaluate(() => window.__reasonixFlushSessionDraft?.()); |
| 83 | await page.locator(".sidebar__quick-action").click(); |
| 84 | const projectFolder = page.locator(".project-tree__folder--project").first(); |
| 85 | const projectCreate = projectFolder.locator(".project-tree__folder-action--create"); |
| 86 | await projectCreate.focus(); |
| 87 | await projectCreate.press("Enter"); |
| 88 | await poll(() => invoke("RestoreSessionDraft"), current => current?.id === draft.id); |
| 89 | assert.equal(await composer.inputValue(), "Keep this draft until confirmed"); |
| 90 | check("project create entry returns to the saved draft after global navigation"); |
| 91 | await page.locator(".draft-topicbar-actions button").click(); |
| 92 | await page.waitForFunction(() => !document.querySelector(".draft-topicbar-actions button")?.disabled); |
| 93 | assert.equal((await invoke("GetSessionDraft", [draft.id])).status, "active"); |
| 94 | assert.equal(await composer.inputValue(), "Keep this draft until confirmed"); |
| 95 | check("cancelled confirmation preserves edited draft"); |
| 96 | await shell.evaluate(() => { globalThis.__topicRemovalAccept = true; }); |
| 97 | await page.locator(".draft-topicbar-actions button").click(); |
| 98 | await waitDiscarded(draft.id); |
| 99 | assert.equal((await invoke("ListTrashEntries", ["", "", 50])).items.length, 0); |
| 100 | assert.equal((await invoke("ListTabs")).length, tabsBefore.length); |
| 101 | check("confirmed draft discard creates neither formal session nor trash entry"); |
| 102 | |
| 103 | const disposable = await invoke("CreateTopic", ["global", "", ""]); |
| 104 | let inspection = await invoke("InspectTopicRemoval", [{ workspaceId: "", topicId: disposable.id }]); |
| 105 | assert.equal(inspection.disposition, "discard_placeholder"); |
| 106 | assert.ok((await invoke("RemoveTopic", [{ operationId: "empty-placeholder", target: inspection.target, expectedToken: inspection.token }])).committed); |
| 107 | assert.equal((await invoke("ListTrashEntries", ["", "", 50])).items.length, 0); |
| 108 | check("legacy default placeholder removed without trash"); |
| 109 | |
| 110 | const named = await invoke("CreateTopic", ["global", "", "Restorable empty plan"]); |
| 111 | inspection = await invoke("InspectTopicRemoval", [{ workspaceId: "", topicId: named.id }]); |
| 112 | await page.reload(); |
| 113 | const globalFolder = page.locator(".project-tree__folder--global .project-tree__folder-main"); |
| 114 | await globalFolder.waitFor(); |
| 115 | if (await globalFolder.getAttribute("aria-expanded") === "false") await globalFolder.click(); |
| 116 | const rowToRemove = page.locator(".project-tree__topic").filter({ hasText: "Restorable empty plan" }).first(); |
| 117 | await rowToRemove.waitFor(); |
| 118 | await rowToRemove.locator(".project-tree__topic-main").click({ button: "right" }); |
| 119 | await page.getByRole("menuitem").last().click(); |
| 120 | await poll(() => invoke("ListTrashEntries", ["", "", 50]), page => page.items.some(row => row.title === named.title)); |
| 121 | let trash = await invoke("ListTrashEntries", ["", "", 50]); |
| 122 | assert.equal(trash.items.length, 1); |
| 123 | assert.equal(trash.items[0].canPreview, false); |
| 124 | const request = { operationId: trash.items[0].recoveryEntryId.replace(/^topic-removal:/, ""), target: inspection.target, expectedToken: inspection.token }; |
| 125 | check("real sidebar action archives named empty topic with a restorable receipt"); |
| 126 | await page.screenshot({ path: join(home, "removed.png") }); |
| 127 | await shell.close(); shell = null; |
| 128 | await launch(); |
| 129 | assert.equal((await invoke("GetSessionDraft", [draft.id])).status, "discarded"); |
| 130 | trash = await invoke("ListTrashEntries", ["", "", 50]); |
| 131 | const row = trash.items.find(row => row.title === named.title); |
| 132 | assert.ok(row?.canRestore); |
| 133 | const result = await invoke("ApplySessionLifecycle", [{ operationId: "restore-placeholder", action: "restore", expectedGeneration: trash.generation, |
| 134 | targets: [{ workspaceId: row.workspaceId, recoveryEntryId: row.recoveryEntryId }] }]); |
| 135 | assert.ok(result.committed); |
| 136 | assert.ok((await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }])).items.some(row => row.topicId === named.id)); |
| 137 | assert.ok((await invoke("RemoveTopic", [request])).committed); |
| 138 | assert.ok((await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }])).items.some(row => row.topicId === named.id)); |
| 139 | check("restart preserves discard and recoverable metadata; late duplicate cannot remove restored topic"); |
| 140 | assert.deepEqual(report.errors, []); |
| 141 | } catch (error) { |
| 142 | report.failure = String(error); |
| 143 | if (page && !page.isClosed()) await page.screenshot({ path: join(home, "failure.png") }).catch(() => {}); |
| 144 | console.error(error); |
| 145 | throw error; |
| 146 | } finally { |
| 147 | if (shell) { |
| 148 | const deadline = setTimeout(() => shell.process().kill("SIGKILL"), 15000); |
| 149 | try { await shell.close().catch(() => {}); } finally { clearTimeout(deadline); } |
| 150 | } |
| 151 | writeFileSync(join(home, "results.json"), JSON.stringify(report, null, 2)); |
| 152 | console.log(`Evidence: ${join(home, "results.json")}`); |
| 153 | } |
| 154 |