返回 DeepSeek-Reasonix
fork-targets.mjs
根目录 / desktop / frontend / bench / fork-targets.mjs
1 // Browser gate for the transcript's turn-fork entry. It builds the bench page
2 // with the real Transcript and drives it in Chromium, reading the rendered
3 // states, labels, and notices out of the DOM.
4 import assert from "node:assert/strict";
5 import { mkdir, mkdtemp, rm } from "node:fs/promises";
6 import { tmpdir } from "node:os";
7 import path from "node:path";
8 import { fileURLToPath } from "node:url";
9 import { build, preview, loadConfigFromFile } from "vite";
10
11 // Browsers resolve from the default Playwright cache; this gate never pins a
12 // worktree-local path.
13 const { chromium } = await import("playwright");
14 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15 const outDir = await mkdtemp(path.join(tmpdir(), "reasonix-fork-build-"));
16 const evidence = process.env.REASONIX_FORK_EVIDENCE ?? path.join(tmpdir(), "reasonix-fork-evidence");
17 await mkdir(evidence, { recursive: true });
18 const loaded = await loadConfigFromFile({ command: "build", mode: "production" }, path.join(root, "vite.config.ts"));
19 const config = loaded.config;
20 await build({ ...config, configFile: false, root, logLevel: "error",
21 plugins: config.plugins.filter(plugin => !["archive-hidden-sourcemaps", "keep-dist-placeholder"].includes(plugin?.name)),
22 build: { ...config.build, outDir, sourcemap: false,
23 rolldownOptions: { ...config.build.rolldownOptions, input: path.join(root, "bench/fork-targets.html") } } });
24 const server = await preview({ configFile: false, root, logLevel: "error", build: { outDir }, preview: { host: "127.0.0.1", port: 0 } });
25 const address = server.httpServer.address();
26 const base = `http://127.0.0.1:${address.port}/bench/fork-targets.html`;
27 const browser = await chromium.launch({ headless: true });
28 const report = { browser: browser.version(), platform: process.platform, arch: process.arch, states: {}, clicks: [], notices: [] };
29
30 // Selectors: the transcript's action row and the branch entry are the shipped
31 // ones; the bench adds only the fixture control surface.
32 const ENTRY = ".chat-actions button.chat-action-icon:not(.copybtn)";
33 const entry = page => page.locator(ENTRY).last();
34 const settle = page => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
35 const described = (page, selector) => page.evaluate((sel) => {
36 // The rendered entry is the LAST turn's, matching the locator used above.
37 const button = Array.from(document.querySelectorAll(sel)).at(-1);
38 const id = button?.getAttribute("aria-describedby");
39 return id ? document.getElementById(id)?.textContent ?? "" : "";
40 }, selector);
41 const state = async (page, name) => {
42 const button = entry(page);
43 await button.waitFor();
44 return { name, disabled: await button.getAttribute("aria-disabled"), unavailable: await button.getAttribute("data-unavailable"),
45 label: await button.getAttribute("aria-label"), described: await described(page, ENTRY) };
46 };
47 // Locale dictionaries load on demand, and until the requested one arrives the
48 // provider renders its English fallback. Every read of localized copy therefore
49 // waits for the text itself, so a green run never depends on load timing.
50 const waitForLabel = (page, expected) => page.waitForFunction(({ selector, expected }) => {
51 const nodes = document.querySelectorAll(selector);
52 return nodes.length > 0 && nodes[nodes.length - 1].getAttribute("aria-label") === expected;
53 }, { selector: ENTRY, expected }, { timeout: 10_000 });
54 const waitForDescribed = (page, expected) => page.waitForFunction(({ selector, expected }) => {
55 const nodes = document.querySelectorAll(selector);
56 const id = nodes.length ? nodes[nodes.length - 1].getAttribute("aria-describedby") : null;
57 return Boolean(id) && document.getElementById(id)?.textContent === expected;
58 }, { selector: ENTRY, expected }, { timeout: 10_000 });
59
60 try {
61 const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
62 const errors = [];
63 page.on("pageerror", (error) => errors.push(error.message));
64 await page.goto(base);
65 await page.locator(".chat-column .chat-turn-tail").last().waitFor();
66 await settle(page);
67
68 // A completed turn offers the persisted boundary of its answer.
69 await waitForLabel(page, "Branch into a new conversation");
70 const completed = await state(page, "completed");
71 await entry(page).focus();
72 await page.waitForFunction((expected) => (document.querySelector('[role="tooltip"]')?.textContent ?? "") === expected,
73 "Branch into a new conversation", { timeout: 10_000 });
74 const tooltip = await page.locator('[role="tooltip"]').textContent();
75 assert.equal(completed.disabled, null, "a completed turn renders an enabled fork entry");
76 assert.equal(completed.unavailable, null, "the enabled entry is not marked unavailable");
77 assert.equal(tooltip, completed.label, "the focused entry explains itself with the branch label");
78 report.states.completed = { ...completed, tooltip };
79
80 // The open turn has no boundary yet.
81 await page.evaluate(() => window.forkFixture.source("open"));
82 await waitForDescribed(page, "This turn has not finished yet, so it has no boundary to branch from.");
83 const open = await state(page, "open");
84 assert.equal(open.disabled, "true", "an unfinished turn renders a disabled fork entry");
85 assert.match(open.described, /has not finished yet/, "the unfinished turn names its reason");
86 report.states.open = open;
87
88 // A source that keeps no turn records proves no boundary.
89 await page.evaluate(() => window.forkFixture.source("recordless"));
90 await waitForDescribed(page, "This turn has no verifiable branch boundary in the session's records.");
91 const recordless = await state(page, "recordless");
92 assert.equal(recordless.disabled, "true", "a recordless source renders a disabled fork entry");
93 assert.match(recordless.described, /no verifiable branch boundary/, "the recordless source names its reason");
94 report.states.recordless = recordless;
95
96 // Clicking the enabled entry creates the child through the real binding.
97 await page.evaluate(() => window.forkFixture.source("completed"));
98 await waitForLabel(page, "Branch into a new conversation");
99 await entry(page).click();
100 await entry(page).click();
101 await page.waitForFunction(() => window.forkFixture.calls().length === 2, null, { timeout: 10_000 });
102 await page.waitForFunction(() => document.querySelectorAll("[data-fork-call]").length === 2, null, { timeout: 10_000 });
103 const calls = await page.evaluate(() => window.forkFixture.calls());
104 const operations = await page.locator("[data-fork-call]").evaluateAll((nodes) => nodes.map((node) => node.textContent ?? ""));
105 assert.equal(calls.length, 2, "each click dispatches one create");
106 assert.deepEqual(calls.map((call) => call.turnId), ["turn-2", "turn-2"], "the click carries the turn identity");
107 assert.ok(calls.every((call) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(call.operationId)),
108 "each acknowledged host operation has an id");
109 assert.notEqual(calls[0].operationId, calls[1].operationId, "the second acknowledged click is a new operation, not a replay");
110 assert.deepEqual(operations, calls.map((call) => call.operationId), "the dispatched operation ids are the rendered ones");
111 report.clicks = calls;
112
113 // A created child whose tab did not open reaches the user as the recovery notice.
114 await page.goto(`${base}?fork-attach-failure=1`);
115 await page.locator(".chat-column .chat-turn-tail").last().waitFor();
116 await waitForLabel(page, "Branch into a new conversation");
117 await entry(page).click();
118 await page.waitForFunction(() => (document.querySelector("[data-fork-notice]")?.textContent ?? "").includes("mock-fork-turn-"), null, { timeout: 10_000 });
119 await entry(page).click();
120 await page.waitForFunction(() => window.forkFixture.calls().length === 2, null, { timeout: 10_000 });
121 const notice = await page.locator("[data-fork-notice]").textContent();
122 const failed = await page.evaluate(() => window.forkFixture.calls());
123 assert.equal(failed[1].operationId, failed[0].operationId, "an unacknowledged attach failure reuses the host operation");
124 assert.ok(notice?.includes(failed[0].operationId), "the recovery notice names the child the failed attach produced");
125 assert.match(notice ?? "", /could not open/, "the notice explains what happened to the child");
126 assert.equal(await page.locator("[data-fork-notice]").count(), 1, "the failure is surfaced, never swallowed");
127 report.notices.push(notice ?? "");
128
129 // The same states and labels read in the second shipped locale. The zh-CN
130 // dictionary arrives on demand, so each read waits for the localized text
131 // the switch is expected to produce.
132 await page.goto(base);
133 await page.locator(".chat-column .chat-turn-tail").last().waitFor();
134 await settle(page);
135 const localeStartedAt = Date.now();
136 await page.evaluate(() => window.forkFixture.locale("zh"));
137 await waitForLabel(page, "在新对话中分支");
138 // Recorded, not asserted: the provider loads a locale dictionary on demand and
139 // renders its English fallback until that chunk lands.
140 report.localeApplyMs = Date.now() - localeStartedAt;
141 const zhCompleted = await state(page, "zh-completed");
142 await page.evaluate(() => window.forkFixture.source("open"));
143 await waitForDescribed(page, "该轮次尚未结束,还没有可供分支的边界。");
144 const zhOpen = await state(page, "zh-open");
145 await page.evaluate(() => window.forkFixture.source("recordless"));
146 await waitForDescribed(page, "该轮次在会话记录中没有可确认的分支边界。");
147 const zhRecordless = await state(page, "zh-recordless");
148 assert.equal(zhCompleted.label, "在新对话中分支", "the enabled entry keeps the branch label in zh-CN");
149 assert.equal(zhCompleted.disabled, null, "a completed turn stays forkable in zh-CN");
150 assert.match(zhOpen.described, /该轮次尚未结束/, "the unfinished-turn reason is localized");
151 assert.match(zhRecordless.described, /没有可确认的分支边界/, "the unverifiable reason is localized");
152 report.states["zh-CN"] = { completed: zhCompleted, open: zhOpen, recordless: zhRecordless };
153
154 await page.screenshot({ path: path.join(evidence, "fork-targets.png") });
155 assert.deepEqual(errors, []);
156 console.log(JSON.stringify(report, null, 2));
157 } finally {
158 await browser.close();
159 await server.httpServer.close();
160 await rm(outDir, { recursive: true, force: true });
161 }
162
162 lines Plain Text