返回 DeepSeek-Reasonix
archive-recovery-smoke.mjs
根目录 / desktop / packaging / archive-recovery-smoke.mjs
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
140 lines Plain Text