返回 DeepSeek-Reasonix
independent-session-native-smoke.mjs
根目录 / desktop / packaging / independent-session-native-smoke.mjs
1 // Real Electron shell + full App + current Go service, with a disposable home.
2 // Build first: cd desktop && go build -o build/bin/reasonix-desktop-service .
3 // Then: cd electron && pnpm build
4 // Also build the renderer: cd desktop/frontend && pnpm build
5 // Run: node desktop/packaging/independent-session-native-smoke.mjs [service] [evidence]
6 import assert from "node:assert/strict";
7 import { createRequire } from "node:module";
8 import { createServer as createHTTPServer } from "node:http";
9 import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
10 import { join, resolve, dirname } from "node:path";
11 import { fileURLToPath } from "node:url";
12 import { tmpdir } from "node:os";
13 import { packagedSmokeEnv } from "./smoke-env.mjs";
14 import { waitForSmokeCondition } from "./smoke-poll.mjs";
15
16 const desktop = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17 const requireShell = createRequire(join(desktop, "electron/package.json"));
18 const requireFrontend = createRequire(join(desktop, "frontend/package.json"));
19 const { _electron } = requireShell("playwright");
20 const { preview } = await import(requireFrontend.resolve("vite"));
21 const service = resolve(process.argv[2] || join(desktop, "build/bin/reasonix-desktop-service"));
22 const evidence = resolve(process.argv[3] || join(tmpdir(), "reasonix-independent-native"));
23 const home = mkdtempSync(join(tmpdir(), "reasonix-independent-native-home-"));
24 mkdirSync(evidence, { recursive: true });
25 const checks = [];
26 const errors = [];
27 const violations = [];
28 let releaseBackground;
29 const record = check => { checks.push(check); console.log(`PASS ${check}`); };
30 const provider = createHTTPServer(async (req, res) => {
31 let raw = "";
32 for await (const chunk of req) raw += chunk;
33 const messages = JSON.stringify(JSON.parse(raw || "{}").messages || []);
34 const content = messages.includes("BACKGROUND_CHILD") ? "ANSWER_BACKGROUND_CHILD"
35 : messages.includes("CHILD_ONLY") ? "ANSWER_CHILD_ONLY" : "ANSWER_PARENT_RETAINED";
36 if (messages.includes("BACKGROUND_CHILD")) await new Promise(resolve => { releaseBackground = resolve; });
37 res.writeHead(200, { "Content-Type": "text/event-stream" });
38 res.end(`data: ${JSON.stringify({ id: "independent-fixture", choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n`
39 + `data: ${JSON.stringify({ id: "independent-fixture", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`);
40 });
41 await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve));
42 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:${provider.address().port}/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "INDEPENDENT_FIXTURE_KEY"\n`);
43 // Qualify the finished renderer build. A cold dev server transforms thousands
44 // of modules during shell startup and is not the shipped renderer artifact.
45 assert.ok(existsSync(join(desktop, "frontend/dist/index.html")), "build the renderer before native qualification");
46 const vite = await preview({ root: join(desktop, "frontend"), logLevel: "error", preview: { host: "127.0.0.1", port: 0 } });
47 let application, page;
48 const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args });
49 const active = async () => {
50 const tabs = await invoke("ListTabs");
51 return tabs.find(tab => tab.active) ?? (tabs.length === 1 ? tabs[0] : undefined);
52 };
53 async function capturePhase(phase) {
54 const snapshot = { tabs: await invoke("ListTabs"), workspace: await invoke("GetWorkspaceSnapshot") };
55 writeFileSync(join(evidence, `phase-${phase}.json`), JSON.stringify(snapshot, null, 2));
56 return snapshot;
57 }
58 async function list() {
59 // Catalog hydration can invalidate a snapshot during startup; the contract
60 // requires a fresh first-page read, never appending to the rejected page.
61 for (let attempt = 0; ; attempt++) {
62 try { return await invoke("ListProjectTopics", [{ scope: "global", workspaceRoot: "", limit: 50 }]); }
63 catch (error) { if (attempt >= 2 || !String(error).includes("stale_cursor")) throw error; }
64 }
65 }
66 const row = title => page.locator(".project-tree__topic-main").filter({ has: page.getByText(title, { exact: true }) });
67 const settle = () => waitForSmokeCondition(async () => (await invoke("ListTabs")).every(tab => !tab.running));
68 async function launch() {
69 application = await _electron.launch({ args: [join(desktop, "electron")], env: {
70 ...packagedSmokeEnv(process.env, home), REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: service,
71 REASONIX_ELECTRON_DEV_URL: `http://127.0.0.1:${vite.httpServer.address().port}`,
72 INDEPENDENT_FIXTURE_KEY: "loopback-only",
73 }, timeout: 60_000 });
74 page = await application.firstWindow();
75 page.setDefaultTimeout(30000);
76 page.on("pageerror", error => errors.push(error.message));
77 await page.waitForFunction(() => Boolean(window.reasonixDesktop));
78 await page.evaluate(() => new Promise((resolve, reject) => {
79 const timeout = setTimeout(() => reject(new Error("native service readiness timed out")), 30000);
80 const off = window.reasonixDesktop.native.onServiceState(state => {
81 if (state.phase === "ready") queueMicrotask(() => { clearTimeout(timeout); off(); resolve(); });
82 else if (["failed", "exited"].includes(state.phase)) queueMicrotask(() => { clearTimeout(timeout); off(); reject(new Error(JSON.stringify(state))); });
83 });
84 }));
85 // Absence of the boot placeholder is also true before React mounts. Wait
86 // for the actual interactive surface before issuing a user navigation;
87 // otherwise the fixture races initial tab restoration with EnsureBlank.
88 await page.locator("textarea").first().waitFor({ state: "visible" });
89 }
90 async function close() { await application.close(); application = null; }
91 async function send(text, response) {
92 const composer = page.locator("textarea").first();
93 await composer.fill(text);
94 await page.locator(".composer__btn--send").click();
95 await page.waitForFunction(text => document.querySelector(".chat-transcript")?.textContent?.includes(text), response);
96 await waitForSmokeCondition(async () => Boolean((await active())?.session?.sessionId));
97 await settle();
98 }
99 async function select(title, ref) {
100 await row(title).click();
101 await waitForSmokeCondition(async () => (await active())?.session?.sessionId === ref.sessionId);
102 await page.waitForFunction(() => document.querySelector(".transcript-navigation-surface")?.getAttribute("aria-busy") === "false"
103 && Boolean(document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_PARENT_RETAINED")));
104 await page.waitForTimeout(500); // Observe post-hydration title rather than the optimistically selected shell.
105 const visibleTitle = await page.locator(".topicbar h1").innerText();
106 if (visibleTitle !== title) violations.push({ check: "active title follows saved session presentation", expected: title, actual: visibleTitle });
107 const activeRows = await page.locator(".project-tree__topic--active").count();
108 if (activeRows !== 1) violations.push({ check: "exactly one selected sidebar row", expected: 1, actual: activeRows, sessionId: ref.sessionId });
109 assert.equal(await page.locator(".project-tree__topic-main").count(), 2, "opening one session must not add a third runtime projection");
110 }
111 try {
112 await launch();
113 const initialBlank = await capturePhase("initial");
114 const version = await invoke("Version");
115 await page.locator(".sidebar__quick-action").click();
116 await page.locator(".session-draft-surface").waitFor({ state: "visible" });
117 const createdBlank = await capturePhase("created-blank");
118 assert.equal(initialBlank.tabs.length, 0, "fresh home starts without a durable session");
119 assert.equal(createdBlank.tabs.length, 0, "blank draft does not create a canonical session before first send");
120 assert.equal(createdBlank.workspace.pendingCreates.length, 0, "blank draft leaves no abandoned pending create");
121 assert.equal(createdBlank.workspace.workspaces.flatMap(workspace => workspace.sessionIds).length, 0,
122 "blank draft leaves no abandoned recovery row");
123 record("blank startup stays a durable draft without abandoned canonical or pending rows");
124 await send("PARENT_RETAINED", "ANSWER_PARENT_RETAINED");
125 await capturePhase("sent");
126 const parent = (await active()).session;
127 await invoke("RenameSessionTarget", [{ ref: parent }, "Native parent A"]);
128 const targets = await invoke("ForkTargetsForTab", [(await active()).id]);
129 const boundary = targets.targets.find(target => target.available);
130 assert.ok(boundary, "completed real provider turn exposes a verifiable fork boundary");
131 const child = await invoke("ForkSessionTarget", [{ ref: parent }, boundary.turnId]);
132 assert.notEqual(parent.sessionId, child.sessionId);
133 await invoke("RenameSessionTarget", [{ ref: child }, "Native child B"]);
134 const forked = await list();
135 const sourceTopic = forked.items.find(node => node.session?.sessionId === parent.sessionId).topicId;
136 await capturePhase("forked");
137 // Seed upgrade-era shared-topic presentation only after the isolated owner stops.
138 // Current forks may receive distinct topic metadata; transcript and SessionIDs
139 // remain untouched so the reopened real service exercises existing A/B history.
140 await close();
141 const registryPath = join(home, "desktop/workspace-state-v1.json");
142 const registry = JSON.parse(readFileSync(registryPath, "utf8"));
143 registry.presentation[child.sessionId].topicId = sourceTopic;
144 writeFileSync(registryPath, JSON.stringify(registry));
145 await launch();
146 const initial = await list();
147 const a = initial.items.find(node => node.session?.sessionId === parent.sessionId);
148 const b = initial.items.find(node => node.session?.sessionId === child.sessionId);
149 assert.ok(a && b);
150 assert.equal(a.topicId, b.topicId, "fixture must exercise the same TopicID");
151 await row("Native parent A").waitFor();
152 await row("Native child B").waitFor();
153 assert.equal(await page.locator(".project-tree__topic-main").count(), 2, "directory and runtime coalesce into the same two logical rows");
154 assert.equal((await row("Native parent A").boundingBox()).x, (await row("Native child B").boundingBox()).x);
155 record("real fork with shared TopicID is displayed as two sibling rows");
156 await select("Native child B", child);
157 await page.locator(".topicbar__title-button").click();
158 await page.locator(".topicbar__title-input").fill("Native child renamed B");
159 await page.locator(".topicbar__title-input").press("Enter");
160 await row("Native child renamed B").waitFor();
161 assert.equal((await list()).items.find(node => node.session?.sessionId === parent.sessionId).label, "Native parent A");
162 await send("CHILD_ONLY", "ANSWER_CHILD_ONLY");
163 assert.ok(!JSON.stringify(await invoke("ReadSessionHistory", [parent, "", 32])).includes("CHILD_ONLY"));
164 record("top rename and further child turn leave parent title/history intact");
165 await page.locator("textarea").first().fill("BACKGROUND_CHILD");
166 await page.locator(".composer__btn--send").click();
167 await waitForSmokeCondition(async () => Boolean(releaseBackground) && Boolean((await active())?.running));
168 await select("Native parent A", parent);
169 releaseBackground();
170 await waitForSmokeCondition(async () => JSON.stringify(await invoke("ReadSessionHistory", [child, "", 32])).includes("ANSWER_BACKGROUND_CHILD"));
171 assert.equal((await active()).session.sessionId, parent.sessionId);
172 assert.ok(!JSON.stringify(await invoke("ReadSessionHistory", [parent, "", 32])).includes("BACKGROUND_CHILD"));
173 record("switching away lets B complete in background without changing A runtime or history");
174 for (const [title, ref] of [["Native parent A", parent], ["Native child renamed B", child], ["Native parent A", parent]]) await select(title, ref);
175 record("A/B/A sidebar navigation resolves the exact native runtime");
176 await row("Native child renamed B").click({ button: "right" });
177 await page.getByRole("menuitem", { name: /Move to trash|移至回收站|移到回收站|移至垃圾桶/ }).click();
178 await page.getByRole("menuitem", { name: /Confirm|确认|確認/ }).click();
179 await waitForSmokeCondition(async () => (await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(child.sessionId));
180 await row("Native child renamed B").waitFor({ state: "hidden" });
181 await row("Native parent A").waitFor();
182 await page.screenshot({ path: join(evidence, "archived-child.png") });
183 await close();
184 await launch();
185 assert.ok((await invoke("GetWorkspaceSnapshot")).archivedSessionIds.includes(child.sessionId));
186 assert.ok(!(await list()).items.some(node => node.session?.sessionId === child.sessionId));
187 assert.ok((await list()).items.some(node => node.session?.sessionId === parent.sessionId));
188 record("native menu archives only B and restart preserves the lifecycle");
189 await invoke("RestoreSessionTarget", [{ ref: child }]);
190 await row("Native child renamed B").waitFor();
191 await select("Native child renamed B", child);
192 await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_CHILD_ONLY"));
193 await close();
194 await launch();
195 await select("Native child renamed B", child);
196 await page.waitForFunction(() => document.querySelector(".chat-transcript")?.textContent?.includes("ANSWER_CHILD_ONLY"));
197 record("restore retains child SessionID and transcript across another native restart");
198 assert.deepEqual(errors, []);
199 assert.deepEqual(violations, [], "all observed native invariants must hold");
200 await page.screenshot({ path: join(evidence, "restored-child.png") });
201 writeFileSync(join(evidence, "result.json"), JSON.stringify({ passed: true, version, parent, child, checks, errors,
202 scope: "real Electron development shell, built production App renderer, real Go service, loopback provider" }, null, 2));
203 } catch (error) {
204 writeFileSync(join(evidence, "result.json"), JSON.stringify({ passed: false, checks, errors, violations, error: String(error.stack) }, null, 2));
205 if (page && !page.isClosed()) {
206 await page.screenshot({ path: join(evidence, "failure.png") }).catch(() => {});
207 writeFileSync(join(evidence, "failure-state.json"), JSON.stringify({
208 body: await page.locator("body").innerText().catch(() => ""), tabs: await invoke("ListTabs").catch(() => null),
209 topics: await list().catch(() => null), workspace: await invoke("GetWorkspaceSnapshot").catch(() => null),
210 }, null, 2));
211 }
212 throw error;
213 } finally {
214 releaseBackground?.();
215 if (application) await close();
216 for (const name of ["shell.log", "service.log"]) {
217 const source = join(home, "desktop-shell/logs", name);
218 if (existsSync(source)) writeFileSync(join(evidence, name), readFileSync(source));
219 }
220 await vite.close();
221 provider.closeAllConnections();
222 await new Promise(resolve => provider.close(resolve));
223 rmSync(home, { recursive: true, force: true });
224 }
225
225 lines Plain Text