| 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 |