返回 DeepSeek-Reasonix
topic-removal-smoke.mjs
根目录 / desktop / electron / scripts / topic-removal-smoke.mjs
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
154 lines Plain Text