返回 DeepSeek-Reasonix
transcript-layout.mjs
根目录 / desktop / frontend / bench / transcript-layout.mjs
1 import assert from "node:assert/strict";
2 import { mkdtemp, rm, mkdir, writeFile, copyFile } from "node:fs/promises";
3 import { createRequire } from "node:module";
4 import { tmpdir } from "node:os";
5 import path from "node:path";
6 import { fileURLToPath } from "node:url";
7 import { build, loadConfigFromFile, preview } from "vite";
8
9 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10 process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
11 ? path.join(root, ".pw-browsers") : process.env.PLAYWRIGHT_BROWSERS_PATH;
12 const { chromium, _electron } = await import("playwright");
13 const electronEngine = process.argv.includes("--electron");
14 if (electronEngine) process.env.REASONIX_SHELL = "electron";
15 const output = await mkdtemp(path.join(tmpdir(), "reasonix-layout-build-"));
16 const evidence = process.env.REASONIX_LAYOUT_ARTIFACTS ?? process.env.REASONIX_LAYOUT_EVIDENCE;
17 const samples = [];
18 let server;
19 let browser;
20 let electronApp;
21 try {
22 const loaded = await loadConfigFromFile({ command: "build", mode: "production" }, path.join(root, "vite.config.ts"));
23 // Build the real component fixture separately; do not alter the application's
24 // dist, sourcemap archive or placeholder while running a regression test.
25 const config = loaded.config;
26 await build({ ...config, configFile: false, root, logLevel: "error",
27 plugins: config.plugins.filter(plugin => !["archive-hidden-sourcemaps", "keep-dist-placeholder"].includes(plugin?.name)),
28 build: { ...config.build, outDir: output, minify: false, sourcemap: false,
29 rolldownOptions: { ...config.build.rolldownOptions, input: path.join(root, "bench/transcript-layout.html") } },
30 });
31 server = await preview({ configFile: false, root, logLevel: "error", build: { outDir: output },
32 preview: { host: "127.0.0.1", port: 0 } });
33 const address = server.httpServer.address();
34 const url = `http://127.0.0.1:${address.port}/bench/transcript-layout.html`;
35 let page;
36 if (electronEngine) {
37 const main = path.join(output, "main.cjs");
38 await copyFile(path.join(root, "bench/transcript-layout-electron.cjs"), main);
39 const electronRequire = createRequire(path.join(root, "../electron/package.json"));
40 electronApp = await _electron.launch({
41 executablePath: electronRequire("electron"), args: [main],
42 env: { ...process.env, REASONIX_LAYOUT_URL: url },
43 });
44 page = await electronApp.firstWindow();
45 } else {
46 browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) });
47 page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
48 }
49 const setViewport = async size => {
50 if (electronApp) await electronApp.evaluate(({ BrowserWindow }, size) => BrowserWindow.getAllWindows()[0].setContentSize(size.width, size.height), size);
51 else await page.setViewportSize(size);
52 await page.waitForFunction(size => Math.abs(innerWidth - size.width) <= 1 && Math.abs(innerHeight - size.height) <= 1, size);
53 };
54 const errors = [];
55 page.on("pageerror", error => { errors.push(error.message); console.error(error.message); });
56 await page.goto(url);
57 await page.waitForSelector(".chat-node .code").catch(async error => {
58 console.error((await page.locator("body").innerText()).slice(0, 1200));
59 throw error;
60 });
61 await page.evaluate(() => document.fonts.ready);
62 const configure = async options => {
63 const revision = await page.evaluate(options => {
64 const fixture = window.transcriptLayoutFixture;
65 const next = fixture.revision + 1;
66 fixture.configure(options);
67 return next;
68 }, options);
69 await page.waitForFunction(revision => window.transcriptLayoutFixture.revision === revision, revision);
70 await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
71 };
72 const measure = async label => {
73 const sample = await page.evaluate(() => {
74 const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, width: r.width }; };
75 const chat = document.querySelector(".chat-pane");
76 const content = document.querySelector(".transcript-navigation-content");
77 const transcript = document.querySelector(".transcript");
78 const launcher = document.querySelector(".dock-launcher");
79 return { viewport: innerWidth, chat: rect(chat), content: rect(content),
80 documentWidth: document.documentElement.scrollWidth,
81 dockOpen: document.querySelector(".layout").classList.contains("layout--workspace-open"),
82 clientWidth: transcript.clientWidth, scrollWidth: transcript.scrollWidth,
83 userBubbles: [...document.querySelectorAll(".msg--user .msg__body")].map(rect),
84 launcher: launcher ? rect(launcher) : null };
85 });
86 samples.push({ label, ...sample });
87 if (sample.scrollWidth > sample.clientWidth + 1) console.error(label, sample, await page.locator(".chat-flow-scroll").evaluate(root => [...root.querySelectorAll("*")].filter(el => el.getBoundingClientRect().right > root.getBoundingClientRect().right + 1).slice(0, 12).map(el => ({ class: el.className, width: el.getBoundingClientRect().width, css: { width: getComputedStyle(el).width, minWidth: getComputedStyle(el).minWidth, padding: getComputedStyle(el).padding, box: getComputedStyle(el).boxSizing, overflow: getComputedStyle(el).overflow } }))));
88 assert.ok(sample.content.right <= sample.chat.right + 1, label + ": content column fits chat");
89 assert.ok(sample.documentWidth <= sample.viewport + 1, label + ": document stays inside viewport");
90 assert.ok(sample.scrollWidth <= sample.clientWidth + 1, label + ": transcript has no horizontal overflow");
91 for (const bubble of sample.userBubbles) {
92 assert.ok(bubble.left >= sample.content.left - 1 && bubble.right <= sample.content.right + 1, label + ": complete user bubble fits");
93 }
94 if (sample.launcher) {
95 assert.ok(sample.launcher.right <= sample.chat.right + 1, label + ": launcher fits");
96 if (!sample.dockOpen) assert.ok(sample.content.right <= sample.launcher.left + 1, label + ": launcher and history do not overlap");
97 }
98 return sample;
99 };
100 // This first case is the original full-width disappearing-message regression.
101 await measure("full-width long code");
102 for (const layout of ["workbench"]) {
103 for (const width of ["standard", "full"]) {
104 for (const viewport of [760, 820, 1000, 1280, 1920]) {
105 await setViewport({ width: viewport, height: 1080 });
106 await configure({ layout, width, sidebar: true, dock: false, launcher: false });
107 await measure(`${layout}/${width}/${viewport}`);
108 }
109 }
110 }
111 await setViewport({ width: 1920, height: 1080 });
112 for (const sidebar of [false, true]) for (const dock of [false, true]) {
113 await configure({ sidebar, dock, launcher: true });
114 await measure(`sidebar=${sidebar}/dock=${dock}`);
115 }
116 await configure({ layout: "workbench", sidebar: false, dock: false, launcher: true });
117 // Account for native DPI rounding and the chat pane's border when straddling
118 // the CSS threshold; assert against the actual rendered surface.
119 for (const width of [1006, 1010, 1014]) {
120 await setViewport({ width, height: 1080 });
121 await page.waitForFunction(() => document.querySelector(".chat-pane").getBoundingClientRect().width >= innerWidth - 2);
122 const surfaceWidth = await page.locator(".chat-pane").evaluate(element => element.getBoundingClientRect().width);
123 if (width < 1010) assert.ok(surfaceWidth < 1010, "fixture reaches below launcher threshold");
124 if (width > 1010) assert.ok(surfaceWidth > 1010, `fixture reaches above launcher threshold: window=${width}, surface=${surfaceWidth}`);
125 await page.waitForFunction(hidden => Boolean(document.querySelector(".dock-launcher")) !== hidden, surfaceWidth < 1010).catch(async error => {
126 console.error("launcher threshold geometry", await page.evaluate(() => ({ innerWidth, devicePixelRatio, mode: document.documentElement.dataset.dockLauncher,
127 surface: document.querySelector(".chat-pane")?.getBoundingClientRect().width,
128 launcherParent: document.querySelector(".dock-launcher")?.parentElement?.getBoundingClientRect().width })));
129 throw error;
130 });
131 await measure("launcher threshold " + width);
132 }
133 await configure({ launcher: false });
134 await page.evaluate(() => {
135 window.layoutMotion = { active: true, widths: [] };
136 const sample = () => {
137 const element = document.querySelector(".transcript-navigation-content");
138 window.layoutMotion.widths.push(element.getBoundingClientRect().width);
139 if (window.layoutMotion.active) requestAnimationFrame(sample);
140 };
141 requestAnimationFrame(sample);
142 });
143 for (let index = 0; index < 4; index++) {
144 await configure({ long: index % 2 === 0 });
145 await measure("content replacement " + index);
146 }
147 const motion = await page.evaluate(() => { window.layoutMotion.active = false; return window.layoutMotion.widths; });
148 assert.ok(motion.length > 4 && Math.max(...motion) - Math.min(...motion) <= 1, "content changes never shift the column between frames");
149 await configure({ turns: 120, long: true });
150 await page.waitForFunction(() => document.querySelectorAll('[data-chat-kind="user"]').length === 120);
151 await measure("accumulated long session");
152 // The active turn may have already moved the virtual rail to its tail.
153 // Put the rail at the loaded start before exercising an early keyboard jump.
154 const turnRail = page.locator('.dsh-TurnNavigator-frame');
155 await turnRail.evaluate(nav => {
156 const scroller = nav.firstElementChild;
157 scroller.scrollTop = 0;
158 scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
159 });
160 await page.locator('[data-nav-turn="user-10"]').waitFor();
161 await page.locator('[data-nav-turn="user-10"]').focus();
162 await page.locator('[data-nav-turn="user-10"]').press("Enter");
163 await page.waitForFunction(() => [...document.querySelectorAll(".transcript code")].some(element => element.textContent.includes("abcdefghij".repeat(60))));
164 // Harness keeps only the visible navigation marks mounted. Move the rail to
165 // its loaded tail before addressing the last turn by keyboard.
166 await turnRail.evaluate(nav => {
167 const scroller = nav.firstElementChild;
168 scroller.scrollTop = scroller.scrollHeight;
169 scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
170 });
171 await page.locator('[data-nav-turn="user-119"]').waitFor();
172 await page.locator('[data-nav-turn="user-119"]').focus();
173 await page.locator('[data-nav-turn="user-119"]').press("Enter");
174 assert.equal(await page.locator('[data-chat-kind="user"]').count(), 120, "reading never unmounts loaded history");
175 await measure("return to loaded tail");
176 await configure({ turns: 1, text: "prefix ", streaming: true });
177 await page.waitForSelector(".msg--assistant .msg__body");
178 const prose = "prefix " + "abcdefghij".repeat(60);
179 await configure({ text: prose });
180 await page.waitForSelector(".msg--assistant .msg__body");
181 await measure("streaming long prose");
182 await configure({ streaming: false });
183 await page.waitForSelector(".md[data-markdown-blocks] p");
184 await measure("completed long prose");
185 const paragraph = await page.locator(".msg--assistant .md p").evaluate(element => ({
186 width: element.clientWidth, scrollWidth: element.scrollWidth, text: element.textContent,
187 }));
188 assert.ok(paragraph.scrollWidth <= paragraph.width + 1, "completed prose wraps inside its own column");
189 assert.equal(paragraph.text, prose, "wrapping never changes source text");
190 await page.reload();
191 await page.waitForSelector(".chat-node .code");
192 await configure({ turns: 1, text: prose });
193 await page.waitForSelector(".md[data-markdown-blocks] p");
194 await measure("fresh history long prose");
195 await configure({ text: "| Key | Value |\n|---|---|\n| " + "abcdefghij".repeat(60) + " | data |\n\n$$\\sum_{n=1}^{10}n$$\n\n\`\`\`text\n" + "abcdefghij".repeat(60) + "\n\`\`\`" });
196 await page.waitForSelector(".md .katex");
197 await measure("wide table and math");
198 const code = await page.locator(".md pre").first().evaluate(element => ({
199 width: element.clientWidth, scrollWidth: element.scrollWidth, whitespace: getComputedStyle(element).whiteSpace,
200 }));
201 assert.equal(code.whitespace, "pre", "ordinary code retains its original lines");
202 assert.ok(code.scrollWidth > code.width, "long code remains locally scrollable");
203 assert.equal(await page.locator(".katex").first().evaluate(element => getComputedStyle(element).overflowWrap), "normal");
204 await configure({ turns: 1, text: null, shell: true });
205 await page.locator(".chat-tool [data-disclosure-row]").click();
206 await page.locator(".dsh-ToolRow-inspectButton").click();
207 await page.waitForSelector(".chat-details");
208 // The unified details host opens on the result tab. The raw-record tab is
209 // the contract that contains both the command arguments and full output.
210 await page.locator(".chat-details__tabs [role='tab']").last().click();
211 // The production tool payload is lazy-loaded. Electron can paint the drawer
212 // before that chunk resolves, so wait for the preview instead of sampling
213 // the Suspense fallback as if it were final content.
214 await page.waitForFunction(() => document.querySelector(".chat-details__body")?.textContent?.includes("END_OF_COMMAND"));
215 await measure("overlay tool details");
216 const commandBox = await page.locator(".chat-details__body").evaluate(element => ({
217 width: element.clientWidth, scrollWidth: element.scrollWidth, text: element.textContent,
218 }));
219 assert.ok(commandBox.scrollWidth <= commandBox.width + 1, "long tool parameters wrap inside the overlay");
220 assert.ok(commandBox.text.includes("END_OF_COMMAND"), "tool parameter preview retains command tail");
221 await page.locator(".chat-details .copybtn").click();
222 await page.waitForFunction(() => window.transcriptLayoutFixture.copied.length === 1);
223 const copiedCommand = await page.evaluate(() => JSON.parse(JSON.parse(window.transcriptLayoutFixture.copied[0]).args).command);
224 assert.equal(copiedCommand, await page.evaluate(() => window.transcriptLayoutFixture.command), "copy loads the exact complete tool payload");
225 await page.keyboard.press("Escape");
226 assert.equal(await page.locator(".chat-details").count(), 0);
227 if (electronApp) {
228 await setViewport({ width: 1280, height: 900 });
229 await configure({ shell: false, text: null, turns: 2, long: true });
230 for (const factor of [0.8, 1, 1.25]) {
231 await electronApp.evaluate(({ BrowserWindow }, factor) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(factor), factor);
232 await page.waitForFunction(factor => Math.abs(innerWidth - 1280 / factor) <= 1, factor);
233 await measure("native Electron zoom " + factor);
234 }
235 }
236 assert.deepEqual(errors, []);
237 console.log(`PASS transcript width (${electronEngine ? "Electron" : "Chromium"}): ${samples.length} geometry scenarios`);
238 if (evidence) {
239 await mkdir(evidence, { recursive: true });
240 await writeFile(path.join(evidence, "environment.json"), JSON.stringify({
241 engine: electronEngine ? "electron" : "chromium", platform: process.platform,
242 versions: electronApp ? await electronApp.evaluate(() => process.versions) : { chromium: browser.version() },
243 sourceCommit: JSON.parse(config.define.__BUILD_COMMIT__),
244 }, null, 2));
245 }
246 if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: path.join(evidence, "layout.png") }); }
247 } finally {
248 if (evidence) { await mkdir(evidence, { recursive: true }); await writeFile(path.join(evidence, "layout.json"), JSON.stringify(samples, null, 2)); }
249 await browser?.close();
250 await electronApp?.close();
251 await server?.httpServer.close();
252 await rm(output, { recursive: true, force: true });
253 }
254
254 lines Plain Text