返回 DeepSeek-Reasonix
history-native-smoke.mjs
根目录 / desktop / packaging / history-native-smoke.mjs
1 // Production macOS package: original JSONL/event history stays readable when
2 // an external writer prevents execution recovery. Fixtures never call a model.
3 // Usage: node desktop/packaging/history-native-smoke.mjs /path/Reasonix.app
4 import assert from "node:assert/strict";
5 import { spawn } from "node:child_process";
6 import { once } from "node:events";
7 import { createRequire } from "node:module";
8 import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
9 import { join, resolve } from "node:path";
10 import { tmpdir } from "node:os";
11 import { packagedSmokeEnv } from "./smoke-env.mjs";
12 import { parseServiceReady, waitForSmokeCondition } from "./smoke-poll.mjs";
13 import { closeAndVerify } from "./smoke-lifecycle.mjs";
14
15 assert.equal(process.platform, "darwin", "This fixture exercises the macOS production bundle");
16 const require = createRequire(new URL("../electron/package.json", import.meta.url));
17 const { _electron } = require("playwright");
18 const bundle = resolve(process.argv[2]);
19 const home = mkdtempSync(join(tmpdir(), "reasonix-native-history-"));
20 const dir = join(home, "sessions");
21 mkdirSync(dir);
22 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:1/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "HISTORY_FIXTURE_KEY"\n`);
23 const answer = "完整内容🧭".repeat(90000);
24 const fixtures = ["checkpoint", "events"].map(kind => {
25 const messages = Array.from({ length: 140 }, (_, index) => [
26 { role: "user", content: `NATIVE_${kind}_QUESTION_${index}` },
27 { role: "assistant", content: index === 139 ? answer : `NATIVE_${kind}_ANSWER_${index}` },
28 ]).flat();
29 const path = join(dir, `${kind}.jsonl`);
30 const eventPath = join(dir, `${kind}.events.jsonl`);
31 const body = kind === "checkpoint" ? messages.map(message => JSON.stringify(message)).join("\n") + "\n"
32 : JSON.stringify({ role: "user", content: "OBSOLETE_CHECKPOINT" }) + "\n";
33 writeFileSync(path, body);
34 const events = JSON.stringify({ messages, type: "replace", schema_version: 1 }) + "\n";
35 if (kind === "events") writeFileSync(eventPath, events);
36 return { kind, path, eventPath, body, events };
37 });
38 let application, lease, page;
39 let passed = false;
40 try {
41 // The OS lock is the real writer fence. No service/runtime test hook is used.
42 lease = spawn("python3", ["-u", "-c", `import fcntl, sys
43 locks = [open(path, 'a+') for path in sys.argv[1:]]
44 for file in locks: fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
45 print('locked', flush=True)
46 sys.stdin.read()
47 `, ...fixtures.map(fixture => fixture.path + ".lease.lock")], { stdio: ["pipe", "pipe", "inherit"] });
48 await Promise.race([once(lease.stdout, "data").then(([value]) => assert.match(String(value), /locked/)),
49 once(lease, "exit").then(() => { throw new Error("fixture writer exited before lock acquisition"); })]);
50 application = await _electron.launch({ executablePath: join(bundle, "Contents/MacOS/Reasonix"),
51 env: { ...packagedSmokeEnv(process.env, home), HISTORY_FIXTURE_KEY: "local-fixture" } });
52 await waitForSmokeCondition(async () => {
53 for (const candidate of application.windows()) {
54 if (await candidate.evaluate(() => Boolean(window.reasonixDesktop)).catch(() => false)) { page = candidate; return true; }
55 }
56 return false;
57 });
58 const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args });
59 const build = JSON.parse(readFileSync(join(bundle, "Contents/Resources/build.json"), "utf8"));
60 assert.equal(await invoke("Version"), build.version);
61 assert.equal(await application.evaluate(({ app }) => app.isPackaged && !process.env.REASONIX_DEV), true);
62 const folder = page.locator(".project-tree__folder-main").first();
63 await folder.waitFor();
64 if (await folder.getAttribute("aria-expanded") !== "true") await folder.click();
65 for (const fixture of fixtures) {
66 await page.locator(".project-tree__topic-main").filter({ has: page.getByText(fixture.kind, { exact: true }) }).click();
67 let selected;
68 await waitForSmokeCondition(async () => {
69 selected = (await invoke("ListTabs")).find(tab => tab.active && tab.sessionPath === fixture.path);
70 return Boolean(selected);
71 });
72 const ticket = { tabId: selected.id };
73 await page.waitForFunction(kind => document.querySelector(".chat-transcript")?.textContent?.includes(`NATIVE_${kind}_QUESTION_139`), fixture.kind);
74 const visible = await page.locator(".chat-transcript").innerText();
75 for (const other of fixtures.filter(other => other !== fixture)) {
76 assert.equal(visible.includes(`NATIVE_${other.kind}_QUESTION_`), false, "previous session remained visible");
77 }
78 const composer = page.locator("#composer-input");
79 await composer.fill("Unsaved draft while the external writer owns execution");
80 assert.equal(await page.locator(".composer__btn--send").isEnabled(), false);
81 await composer.fill("");
82 const handle = await invoke("BeginSessionHistoryReadForTab", [ticket.tabId]);
83 assert.equal(handle.storageBackend, "legacy");
84 const newest = await invoke("ReadSessionHistorySlice", [handle.id, { entries: 32, turns: 32, bytes: 1 << 20 }]);
85 assert.equal(newest.status, "ready");
86 assert.equal(newest.page.hasOlder, true);
87 assert.equal(newest.page.entries.length <= 32, true);
88 const outline = await invoke("ReadSessionHistoryOutline", [handle.id, { limit: 128 }]);
89 assert.equal(outline.status, "ready");
90 assert.equal(outline.entries.length, 128);
91 assert.equal(outline.totalTurns, 140);
92 const target = outline.entries[3];
93 const location = await invoke("LocateSessionHistoryMessage", [handle.id, target.messageId, outline.snapshotSequence]);
94 assert.equal(location.status, "ready");
95 const located = await invoke("ReadSessionHistorySlice", [handle.id, { anchor: "turn", turn: 4, entries: 2, generation: outline.generation, snapshotSequence: outline.snapshotSequence }]);
96 assert.equal(located.status, "ready");
97 assert.equal(located.page.entries.at(-1).message.content, `NATIVE_${fixture.kind}_QUESTION_3`);
98 assert.ok(handle.capabilities.includes("history-native-search-v1"));
99 let search;
100 await waitForSmokeCondition(async () => {
101 search = await invoke("SearchSessionHistoryRead", [handle.id, `NATIVE_${fixture.kind}_QUESTION_`, "", 2]);
102 assert.ok(["preparing", "ready"].includes(search.status));
103 if (search.status === "preparing") assert.equal(search.hits.length, 0);
104 return search.status === "ready";
105 });
106 assert.equal(search.hits.length, 2);
107 assert.equal(search.hasMore, true);
108 assert.equal(search.hits[0].preview, `NATIVE_${fixture.kind}_QUESTION_139`);
109 const searchLocation = await invoke("LocateSessionHistoryMessage", [handle.id, search.hits[0].messageId, search.snapshotSequence]);
110 assert.equal(searchLocation.status, "ready");
111 const nextSearch = await invoke("SearchSessionHistoryRead", [handle.id, `NATIVE_${fixture.kind}_QUESTION_`, search.nextCursor, 2]);
112 assert.equal(nextSearch.status, "ready");
113 assert.equal(nextSearch.hits[0].preview, `NATIVE_${fixture.kind}_QUESTION_137`);
114 const ref = newest.page.entries.flatMap(entry => entry.refs ?? []).find(ref => ref.field === "content");
115 assert.equal(ref?.readHandleId, handle.id);
116 let text = "";
117 for (let index = 0; ; index++) {
118 const chunk = await invoke("HistoryContentForTab", [ticket.tabId, ref, index]);
119 assert.equal(chunk.stale, false);
120 text += chunk.data;
121 if (chunk.done) break;
122 }
123 assert.equal(text, answer);
124 await invoke("ReleaseSessionHistoryRead", [handle.id]);
125 assert.equal((await invoke("HistoryContentForTab", [ticket.tabId, ref, 0])).stale, true);
126 const reopen = await invoke("BeginSessionHistoryReadForTab", [ticket.tabId]);
127 assert.equal((await invoke("ReadSessionHistorySlice", [reopen.id, { entries: 2 }])).status, "ready");
128 assert.equal((await invoke("HistoryContentForTab", [ticket.tabId, ref, 0])).stale, true);
129 await invoke("ReleaseSessionHistoryRead", [reopen.id]);
130 const compatibility = await invoke("HistorySliceForTab", [ticket.tabId, { entries: 2 }]);
131 const compatibilityRef = compatibility.entries.flatMap(entry => entry.refs ?? []).find(ref => ref.field === "content");
132 assert.ok(compatibilityRef, JSON.stringify({ source: compatibility.source, error: compatibility.error,
133 stale: compatibility.stale, entries: compatibility.entries.map(entry => ({ id: entry.id, refs: entry.refs?.length })) }));
134 assert.equal(compatibilityRef.readHandleId ?? "", "");
135 const compatibilityChunk = await invoke("HistoryContentForTab", [ticket.tabId, compatibilityRef, 0]);
136 assert.equal(compatibilityChunk.stale, false);
137 assert.ok(compatibilityChunk.data && answer.startsWith(compatibilityChunk.data));
138 await waitForSmokeCondition(async () => (await invoke("ListTabs")).find(tab => tab.id === ticket.tabId)?.runtime?.phase === "lease_blocked");
139 assert.equal(readFileSync(fixture.path, "utf8"), fixture.body);
140 if (fixture.kind === "events") assert.equal(readFileSync(fixture.eventPath, "utf8"), fixture.events);
141 assert.equal(existsSync(fixture.path.replace(/\.jsonl$/, ".display-index.json")), false);
142 console.log(`PASS ${fixture.kind}: bounded pages, outline, direct anchor, cold search and hit location, complete Unicode content, released refs fenced, unbound content, writer conflict; source unchanged`);
143 }
144 await waitForSmokeCondition(async () => {
145 const topics = await invoke("ListProjectTopics", [{ scope: "global", limit: 50 }]);
146 assert.equal(topics.failedDirectories, 0, "optional absent history roots reported as failed");
147 return topics.complete && topics.pendingDirectories === 0;
148 });
149 const workspace = { scope: "global" };
150 let organization = await invoke("GetSessionOrganization", [workspace]);
151 const mutate = async mutation => {
152 // Match the production client's CAS contract: first-time legacy preference
153 // import may advance the revision before the requested mutation is applied.
154 for (let attempt = 0; attempt < 3; attempt++) {
155 organization = await invoke("UpdateSessionOrganization", [workspace, organization.revision, mutation]);
156 if (organization.applied) return;
157 }
158 assert.fail("organization mutation could not settle under the production CAS contract");
159 };
160 await mutate({ kind: "create-group", groupId: "native-fixtures", title: "Native fixtures" });
161 const sources = (await invoke("ListProjectTopics", [{ scope: "global", limit: 50 }])).items
162 .filter(node => fixtures.some(fixture => fixture.path === node.sessionPath));
163 assert.equal(sources.length, 2);
164 for (const node of sources) await mutate({ kind: "set-group", groupId: "native-fixtures", target: { source: node.source } });
165 const filtered = await invoke("ListProjectTopics", [{ scope: "global", groupFilter: "group", groupId: "native-fixtures", query: "CHECKPOINT", limit: 1 }]);
166 assert.equal(filtered.items.length, 1);
167 assert.equal(filtered.items[0].sessionPath, fixtures.find(fixture => fixture.kind === "checkpoint").path);
168 assert.equal(filtered.nextCursor ?? "", "");
169 await invoke("ReleaseReadSnapshot", [filtered.snapshotId]);
170 const groupedRequest = { scope: "global", groupFilter: "group", groupId: "native-fixtures", limit: 1 };
171 const first = await invoke("ListProjectTopics", [groupedRequest]);
172 assert.equal(first.items.length, 1);
173 assert.ok(first.nextCursor);
174 const removed = sources.find(node => node.sessionPath !== first.items[0].sessionPath);
175 await mutate({ kind: "set-group", groupId: "", target: { source: removed.source } });
176 const retained = await invoke("ListProjectTopics", [{ ...groupedRequest, cursor: first.nextCursor }]);
177 assert.equal(retained.items.length, 1);
178 assert.equal(retained.items[0].sessionPath, removed.sessionPath);
179 assert.equal(retained.nextCursor ?? "", "");
180 await invoke("ReleaseReadSnapshot", [first.snapshotId]);
181 const fresh = await invoke("ListProjectTopics", [groupedRequest]);
182 assert.equal(fresh.items.length, 1);
183 assert.equal(fresh.items[0].sessionPath, first.items[0].sessionPath);
184 assert.equal(fresh.nextCursor ?? "", "");
185 await invoke("ReleaseReadSnapshot", [fresh.snapshotId]);
186 console.log("PASS native group paging: source membership, text filtering, retained cursor, refreshed membership");
187 const ready = parseServiceReady(readFileSync(join(home, "desktop-shell/logs/shell.log"), "utf8"));
188 assert.ok(ready);
189 await closeAndVerify(application, { shellPid: await application.evaluate(() => process.pid), servicePid: ready.pid });
190 application = undefined;
191 console.log(`PASS production history ${build.version} ${build.commit ?? ""}: normal shell/service exit`);
192 passed = true;
193 } catch (error) {
194 if (page) {
195 await page.screenshot({ path: join(home, "failure.png"), fullPage: true }).catch(() => {});
196 const state = await page.evaluate(async () => ({ body: document.body.innerText,
197 tabs: await window.reasonixDesktop.invoke("ListTabs", []),
198 tree: await window.reasonixDesktop.invoke("GetProjectTreeSnapshot", []),
199 topics: await window.reasonixDesktop.invoke("ListProjectTopics", [{ scope: "global", limit: 50 }]),
200 })).catch(error => ({ error: String(error) }));
201 writeFileSync(join(home, "failure.json"), JSON.stringify(state, null, 2));
202 }
203 throw error;
204 } finally {
205 await application?.close();
206 if (lease && lease.exitCode === null) { const exited = once(lease, "exit"); lease.stdin.end(); await exited; }
207 if (passed) rmSync(home, { recursive: true, force: true });
208 else console.error(`Native history evidence retained at ${home}`);
209 }
210
210 lines Plain Text