| 1 | // Real packaged UI: archive -> restart -> Trash restore -> restart -> history. |
| 2 | // Uses a disposable home and a loopback fixture provider; no personal data. |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import { createServer } from "node:http"; |
| 6 | import { mkdtempSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; |
| 7 | import { join, resolve } from "node:path"; |
| 8 | import { tmpdir } from "node:os"; |
| 9 | import { packagedSmokeEnv } from "./smoke-env.mjs"; |
| 10 | import { waitForSmokeCondition } from "./smoke-poll.mjs"; |
| 11 | |
| 12 | const require = createRequire(new URL("../electron/package.json", import.meta.url)); |
| 13 | const { _electron } = require("playwright"); |
| 14 | const home = mkdtempSync(join(tmpdir(), "reasonix-archive-recovery-")); |
| 15 | const artifacts = resolve(process.argv[3] || "desktop/electron/artifacts/archive-recovery"); |
| 16 | mkdirSync(artifacts, { recursive: true }); |
| 17 | const server = createServer(async (req, res) => { |
| 18 | for await (const chunk of req) { /* drain the bounded local fixture request */ } |
| 19 | res.writeHead(200, { "Content-Type": "text/event-stream" }); |
| 20 | res.end(`data: ${JSON.stringify({ id: "archive-fixture", choices: [{ index: 0, delta: { content: "ARCHIVE_HISTORY_RETAINED" }, finish_reason: null }] })}\n\n` |
| 21 | + `data: ${JSON.stringify({ id: "archive-fixture", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`); |
| 22 | }); |
| 23 | await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); |
| 24 | 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:${server.address().port}/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "ARCHIVE_FIXTURE_KEY"\n`); |
| 25 | let application, page; |
| 26 | const errors = []; |
| 27 | const launch = async () => { |
| 28 | application = await _electron.launch({ executablePath: join(process.argv[2], "Contents/MacOS/Reasonix"), |
| 29 | env: { ...packagedSmokeEnv(process.env, home), ARCHIVE_FIXTURE_KEY: "loopback-only" } }); |
| 30 | page = await application.firstWindow(); |
| 31 | page.setDefaultTimeout(30000); |
| 32 | page.on("pageerror", error => errors.push(error.message)); |
| 33 | await page.waitForFunction(() => Boolean(window.reasonixDesktop)); |
| 34 | await page.waitForFunction(() => !document.querySelector(".boot-shell")); |
| 35 | assert.equal(await page.evaluate(() => window.reasonixDesktop.contract.protocolVersion), 10); |
| 36 | }; |
| 37 | const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args }); |
| 38 | const close = async () => { await application.close(); application = null; }; |
| 39 | const recordRegistry = name => writeFileSync(join(artifacts, `${name}.json`), readFileSync(join(home, "desktop/workspace-state-v1.json"))); |
| 40 | const active = async () => (await invoke("ListTabs")).find(tab => tab.active); |
| 41 | try { |
| 42 | await launch(); |
| 43 | const version = await invoke("Version"); |
| 44 | await invoke("EnsureBlankSurface", ["global", ""]); |
| 45 | const composer = page.locator("textarea").first(); |
| 46 | await composer.fill("ARCHIVE_RESTART_FIXTURE"); |
| 47 | await page.locator(".composer__btn--send").click(); |
| 48 | await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ARCHIVE_HISTORY_RETAINED")); |
| 49 | await waitForSmokeCondition(async () => (await invoke("ListTabs")).every(tab => !tab.running)); |
| 50 | const ref = (await active()).session; |
| 51 | await invoke("RenameCanonicalSession", [ref, "Archive restart fixture"]); |
| 52 | const initialTopics = await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }]); |
| 53 | const initialTopic = initialTopics.items.find(node => node.session?.sessionId === ref.sessionId); |
| 54 | assert.ok(initialTopic); |
| 55 | await invoke("SetTopicPinned", [initialTopic.topicId, true]); |
| 56 | // Use the actual sidebar archive action; its handler invokes the lifecycle owner. |
| 57 | const row = page.locator(".project-tree__topic").filter({ has: page.getByText("Archive restart fixture", { exact: true }) }).first(); |
| 58 | await row.waitFor(); |
| 59 | await row.hover(); |
| 60 | const archiveButton = row.locator(".project-tree__topic-action--archive"); |
| 61 | await archiveButton.click(); |
| 62 | await waitForSmokeCondition(async () => (await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(ref.sessionId)); |
| 63 | await page.screenshot({ path: join(artifacts, "archived.png") }); |
| 64 | await close(); |
| 65 | await launch(); |
| 66 | assert.ok((await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(ref.sessionId)); |
| 67 | // Management navigation uses the real UI, not a component mock. |
| 68 | const trashButton = page.getByRole("button", { name: /回收站|Trash|垃圾桶/i }).first(); |
| 69 | await trashButton.click(); |
| 70 | await page.getByText("Archive restart fixture", { exact: true }).waitFor(); |
| 71 | const tabsBeforePreview = await invoke("ListTabs"); |
| 72 | await page.locator(".archived-sessions__open:visible").click(); |
| 73 | await page.locator(".archived-sessions__preview").getByText("ARCHIVE_HISTORY_RETAINED", { exact: true }).waitFor(); |
| 74 | assert.deepEqual(await invoke("ListTabs"), tabsBeforePreview, "preview created a writable runtime"); |
| 75 | await page.screenshot({ path: join(artifacts, "trash-after-restart.png") }); |
| 76 | await page.locator(".archived-sessions__row:visible").getByRole("button", { name: /恢复|Restore|還原/i }).click(); |
| 77 | await waitForSmokeCondition(async () => !(await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(ref.sessionId)); |
| 78 | await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ARCHIVE_HISTORY_RETAINED")); |
| 79 | assert.equal((await active()).session.sessionId, ref.sessionId); |
| 80 | await close(); |
| 81 | await launch(); |
| 82 | await page.locator(".project-tree__topic-main").filter({ hasText: "Archive restart fixture" }).first().click(); |
| 83 | await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ARCHIVE_HISTORY_RETAINED")); |
| 84 | const history = await invoke("ReadSessionHistory", [ref, "", 32]); |
| 85 | assert.ok(JSON.stringify(history).includes("ARCHIVE_HISTORY_RETAINED")); |
| 86 | assert.equal((await active()).session.sessionId, ref.sessionId); |
| 87 | const listStarted = performance.now(); |
| 88 | const topics = await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }]); |
| 89 | const listLatencyMS = Math.round((performance.now() - listStarted) * 100) / 100; |
| 90 | const containsSession = nodes => nodes.some(node => node.session?.sessionId === ref.sessionId || containsSession(node.children || [])); |
| 91 | assert.ok(containsSession(topics.items)); |
| 92 | const restoredTopic = topics.items.find(node => node.session?.sessionId === ref.sessionId); |
| 93 | assert.equal(restoredTopic?.topicId, initialTopic.topicId); |
| 94 | assert.equal(restoredTopic?.pinned, true); |
| 95 | const diagnostics = await invoke("GetSessionArchitectureDiagnostics"); |
| 96 | writeFileSync(join(artifacts, "recovery-diagnostics.json"), JSON.stringify(await invoke("ListRecoveryEntries", ["", "", 50]), null, 2)); |
| 97 | for (const key of ["pending_operations", "missing_members", "identity_mismatches", "source_conflicts", "recovery_entries"]) { |
| 98 | assert.equal(diagnostics[key], 0, `clean fixture has unexpected ${key}`); |
| 99 | } |
| 100 | assert.deepEqual(errors, []); |
| 101 | await page.screenshot({ path: join(artifacts, "restored-after-second-restart.png") }); |
| 102 | const restoredRow = page.locator(".project-tree__topic").filter({ has: page.getByText("Archive restart fixture", { exact: true }) }).first(); |
| 103 | await restoredRow.hover(); |
| 104 | await restoredRow.locator(".project-tree__topic-action--archive").click(); |
| 105 | await waitForSmokeCondition(async () => (await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(ref.sessionId)); |
| 106 | await page.getByRole("button", { name: /回收站|Trash|垃圾桶/i }).first().click(); |
| 107 | await page.locator(".archived-sessions__delete:visible").click(); |
| 108 | await page.getByRole("dialog").getByRole("button", { name: /彻底删除|Permanently delete|徹底刪除/i }).click(); |
| 109 | await waitForSmokeCondition(async () => (await invoke("ListTrashEntries", ["", "", 50])).items.length === 0); |
| 110 | recordRegistry("purge-observed"); |
| 111 | await page.screenshot({ path: join(artifacts, "purged.png") }); |
| 112 | await close(); |
| 113 | recordRegistry("purge-closed"); |
| 114 | await launch(); |
| 115 | recordRegistry("purge-restarted"); |
| 116 | assert.equal((await invoke("ListTrashEntries", ["", "", 50])).items.length, 0); |
| 117 | assert.equal(containsSession((await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }])).items), false); |
| 118 | await assert.rejects(invoke("ReadSessionHistory", [ref, "", 32])); |
| 119 | await close(); |
| 120 | writeFileSync(join(artifacts, "result.json"), JSON.stringify({ passed: true, version, protocol: 10, listLatencyMS, diagnostics, checks: ["real sidebar archive", "persisted archive after restart", "read-only preview", "real Trash restore", "same SessionRef", "history after second restart", "confirmed purge", "no resurrection after restart", "clean exit"] }, null, 2)); |
| 121 | console.log("PASS packaged archive/restart/Trash restore/restart"); |
| 122 | } catch (error) { |
| 123 | if (page && !page.isClosed()) { |
| 124 | writeFileSync(join(artifacts, "failure-state.json"), JSON.stringify({ |
| 125 | trash: await invoke("ListTrashEntries", ["", "", 50]).catch(() => null), |
| 126 | workspace: await invoke("GetWorkspaceSnapshot").catch(() => null), |
| 127 | topics: await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }]).catch(() => null), |
| 128 | tree: await invoke("GetProjectTreeSnapshot").catch(() => null), |
| 129 | tabs: await invoke("ListTabs").catch(() => null), |
| 130 | }, null, 2)); |
| 131 | await page.screenshot({ path: join(artifacts, "failure.png") }).catch(() => {}); |
| 132 | writeFileSync(join(artifacts, "failure.txt"), `${error.stack}\n${await page.locator("body").innerText().catch(() => "")}`); |
| 133 | } |
| 134 | throw error; |
| 135 | } finally { |
| 136 | if (application) await application.close(); |
| 137 | await new Promise(resolve => server.close(resolve)); |
| 138 | rmSync(home, { recursive: true, force: true }); |
| 139 | } |
| 140 |