| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import http from "node:http"; |
| 4 | import path from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { startPreviewServer } from "./vite-preview-server.mjs"; |
| 7 | import { selectSession } from "./app-page-actions.mjs"; |
| 8 | |
| 9 | const frontendDir = 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(frontendDir, ".pw-browsers") |
| 12 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 13 | const { chromium } = await import("playwright"); |
| 14 | const port = Number(process.env.REASONIX_COMPOSER_SCROLL_PORT ?? 4622); |
| 15 | const url = `http://127.0.0.1:${port}/?mock=bench&bench=1&platform=windows`; |
| 16 | |
| 17 | function assert(condition, message) { |
| 18 | if (!condition) throw new Error(message); |
| 19 | process.stdout.write(` PASS ${message}\n`); |
| 20 | } |
| 21 | |
| 22 | async function waitForServer() { |
| 23 | const deadline = Date.now() + 30_000; |
| 24 | while (Date.now() < deadline) { |
| 25 | const ready = await new Promise((resolve) => { |
| 26 | const request = http.get(url, (response) => { |
| 27 | response.resume(); |
| 28 | resolve((response.statusCode ?? 500) < 500); |
| 29 | }); |
| 30 | request.on("error", () => resolve(false)); |
| 31 | }); |
| 32 | if (ready) return; |
| 33 | await new Promise((resolve) => setTimeout(resolve, 150)); |
| 34 | } |
| 35 | throw new Error("composer scroll test server did not become ready"); |
| 36 | } |
| 37 | |
| 38 | async function clickIfVisible(page, selector) { |
| 39 | const locator = page.locator(selector); |
| 40 | if (await locator.count() > 0 && await locator.first().isVisible()) { |
| 41 | await locator.first().click(); |
| 42 | return true; |
| 43 | } |
| 44 | return false; |
| 45 | } |
| 46 | |
| 47 | async function waitForTail(page) { |
| 48 | try { |
| 49 | await page.waitForFunction(() => { |
| 50 | const transcript = document.querySelector(".transcript"); |
| 51 | return transcript instanceof HTMLElement |
| 52 | && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 4; |
| 53 | }, undefined, { timeout: 15_000 }); |
| 54 | } catch (error) { |
| 55 | const state = await page.evaluate(() => { |
| 56 | const transcript = document.querySelector(".transcript"); |
| 57 | return transcript instanceof HTMLElement ? { |
| 58 | top: transcript.scrollTop, |
| 59 | height: transcript.scrollHeight, |
| 60 | clientHeight: transcript.clientHeight, |
| 61 | distance: transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight, |
| 62 | mode: transcript.dataset.scrollMode, |
| 63 | jumpBottom: Boolean(document.querySelector(".chat-to-bottom:not([hidden])")), |
| 64 | } : null; |
| 65 | }); |
| 66 | throw new Error(`composer fixture did not reach the physical tail (${JSON.stringify(state)})`, { cause: error }); |
| 67 | } |
| 68 | await page.evaluate(() => new Promise((resolve) => { |
| 69 | let previous = null; |
| 70 | let stableFrames = 0; |
| 71 | const sample = () => { |
| 72 | const transcript = document.querySelector(".transcript"); |
| 73 | if (!(transcript instanceof HTMLElement)) return requestAnimationFrame(sample); |
| 74 | const current = [transcript.scrollTop, transcript.scrollHeight, transcript.clientHeight]; |
| 75 | const unchanged = previous != null && current.every((value, index) => Math.abs(value - previous[index]) <= 0.5); |
| 76 | stableFrames = unchanged ? stableFrames + 1 : 0; |
| 77 | previous = current; |
| 78 | if (stableFrames >= 6) resolve(); |
| 79 | else requestAnimationFrame(sample); |
| 80 | }; |
| 81 | requestAnimationFrame(sample); |
| 82 | })); |
| 83 | } |
| 84 | |
| 85 | async function resetScrollProbe(page) { |
| 86 | return page.evaluate(() => { |
| 87 | const transcript = document.querySelector(".transcript"); |
| 88 | if (!(transcript instanceof HTMLElement)) throw new Error("transcript is unavailable"); |
| 89 | window.__composerScrollProbe = { samples: [], writes: [] }; |
| 90 | window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__composerScrollProbe.writes.push(write); |
| 91 | const record = (source) => window.__composerScrollProbe.samples.push({ |
| 92 | source, |
| 93 | top: transcript.scrollTop, |
| 94 | height: transcript.scrollHeight, |
| 95 | clientHeight: transcript.clientHeight, |
| 96 | distance: transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight, |
| 97 | at: performance.now(), |
| 98 | }); |
| 99 | if (!window.__composerScrollProbeInstalled) { |
| 100 | transcript.addEventListener("scroll", () => record("scroll"), { passive: true }); |
| 101 | new ResizeObserver(() => record("resize")).observe(transcript); |
| 102 | window.__composerScrollProbeInstalled = true; |
| 103 | } |
| 104 | record("baseline"); |
| 105 | return window.__composerScrollProbe.samples[0]; |
| 106 | }); |
| 107 | } |
| 108 | |
| 109 | async function readScrollProbe(page) { |
| 110 | return page.evaluate(() => { |
| 111 | const transcript = document.querySelector(".transcript"); |
| 112 | const probe = window.__composerScrollProbe; |
| 113 | window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; |
| 114 | if (!(transcript instanceof HTMLElement) || !probe) throw new Error("composer scroll probe is unavailable"); |
| 115 | return { |
| 116 | samples: probe.samples, |
| 117 | writes: probe.writes, |
| 118 | final: { |
| 119 | top: transcript.scrollTop, |
| 120 | height: transcript.scrollHeight, |
| 121 | clientHeight: transcript.clientHeight, |
| 122 | distance: transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight, |
| 123 | mode: transcript.dataset.scrollMode, |
| 124 | }, |
| 125 | }; |
| 126 | }); |
| 127 | } |
| 128 | |
| 129 | async function settleFrames(page, count = 4) { |
| 130 | await page.evaluate((frames) => new Promise((resolve) => { |
| 131 | const settle = () => { |
| 132 | frames -= 1; |
| 133 | if (frames <= 0) resolve(); |
| 134 | else requestAnimationFrame(settle); |
| 135 | }; |
| 136 | requestAnimationFrame(settle); |
| 137 | }), count); |
| 138 | } |
| 139 | |
| 140 | const server = await startPreviewServer(frontendDir, port); |
| 141 | |
| 142 | let browser; |
| 143 | try { |
| 144 | await waitForServer(); |
| 145 | browser = await chromium.launch({ |
| 146 | headless: true, |
| 147 | ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}), |
| 148 | }); |
| 149 | const page = await browser.newPage({ |
| 150 | viewport: { width: 1095, height: 720 }, |
| 151 | deviceScaleFactor: 2, |
| 152 | }); |
| 153 | const pageErrors = []; |
| 154 | page.on("pageerror", (error) => pageErrors.push(error.message)); |
| 155 | await page.goto(url, { waitUntil: "domcontentloaded" }); |
| 156 | await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 }); |
| 157 | await selectSession(page, "bench:tools-38t"); |
| 158 | await page.waitForFunction(() => ( |
| 159 | document.querySelector('.project-tree__topic--active .project-tree__topic-label')?.textContent?.includes("bench:tools-38t") |
| 160 | && document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go") |
| 161 | ), undefined, { timeout: 30_000 }); |
| 162 | await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 }); |
| 163 | |
| 164 | await clickIfVisible(page, ".chat-to-bottom"); |
| 165 | await waitForTail(page); |
| 166 | |
| 167 | const input = page.locator("textarea.composer__input:not(.composer__input--measure)"); |
| 168 | await input.fill("existing first line\nexisting second line"); |
| 169 | await waitForTail(page); |
| 170 | const multilineHeight = await input.evaluate((element) => element.getBoundingClientRect().height); |
| 171 | assert(multilineHeight > 32, `fixture starts with an existing multiline draft (${multilineHeight.toFixed(1)}px)`); |
| 172 | |
| 173 | const baseline = await resetScrollProbe(page); |
| 174 | |
| 175 | await input.focus(); |
| 176 | await input.press("End"); |
| 177 | await input.type("abcdef", { delay: 80 }); |
| 178 | for (let index = 0; index < 6; index += 1) { |
| 179 | await input.press("Backspace"); |
| 180 | await page.waitForTimeout(80); |
| 181 | } |
| 182 | await page.waitForTimeout(350); |
| 183 | |
| 184 | const result = await readScrollProbe(page); |
| 185 | const minTop = Math.min(baseline.top, ...result.samples.map((sample) => sample.top)); |
| 186 | const maxReverse = baseline.top - minTop; |
| 187 | const geometryChanges = result.samples.filter((sample) => ( |
| 188 | Math.abs(sample.height - baseline.height) > 0.5 || sample.clientHeight !== baseline.clientHeight |
| 189 | )); |
| 190 | assert(maxReverse <= 1, `ordinary input/delete never displaces scrollTop away from the tail (${maxReverse.toFixed(1)}px)`); |
| 191 | assert(geometryChanges.length === 0, `ordinary input/delete keeps transcript geometry stable (${geometryChanges.length} changes)`); |
| 192 | assert(result.final.mode === "tail" && result.final.distance <= 4, |
| 193 | `ordinary input/delete finishes at the physical tail (${result.final.distance.toFixed(1)}px)`); |
| 194 | |
| 195 | // Locate the exact character that causes a visual line wrap at this viewport, |
| 196 | // then replay only that character while observing the reader. A real growth |
| 197 | // may advance the physical tail, but it must never move in reverse or bounce. |
| 198 | await input.fill("xxxxxxxx"); |
| 199 | await waitForTail(page); |
| 200 | const singleLineHeight = await input.evaluate((element) => element.getBoundingClientRect().height); |
| 201 | let lastSingleLineLength = 8; |
| 202 | let firstWrappedLength = 256; |
| 203 | while (lastSingleLineLength + 1 < firstWrappedLength) { |
| 204 | const candidate = Math.floor((lastSingleLineLength + firstWrappedLength) / 2); |
| 205 | await input.fill("x".repeat(candidate)); |
| 206 | await settleFrames(page, 2); |
| 207 | const height = await input.evaluate((element) => element.getBoundingClientRect().height); |
| 208 | if (height <= singleLineHeight + 1) lastSingleLineLength = candidate; |
| 209 | else firstWrappedLength = candidate; |
| 210 | } |
| 211 | await input.fill("x".repeat(lastSingleLineLength)); |
| 212 | await waitForTail(page); |
| 213 | const wrapBaseline = await resetScrollProbe(page); |
| 214 | await input.type("x"); |
| 215 | await page.waitForTimeout(350); |
| 216 | const wrapResult = await readScrollProbe(page); |
| 217 | const wrappedHeight = await input.evaluate((element) => element.getBoundingClientRect().height); |
| 218 | const wrapTops = [wrapBaseline.top, ...wrapResult.samples.map((sample) => sample.top)]; |
| 219 | const wrapDistinctTops = [...new Set(wrapTops.map((top) => Math.round(top * 2) / 2))]; |
| 220 | assert(wrappedHeight > singleLineHeight + 1, |
| 221 | `fixture crosses exactly one visual line boundary (${singleLineHeight.toFixed(1)}px → ${wrappedHeight.toFixed(1)}px)`); |
| 222 | assert(Math.min(...wrapTops) >= wrapBaseline.top - 1, |
| 223 | `a real line wrap moves only toward the new tail (${wrapBaseline.top.toFixed(1)}px → ${wrapResult.final.top.toFixed(1)}px)`); |
| 224 | assert(wrapDistinctTops.length <= 2, |
| 225 | `a real line wrap performs at most one visible tail adjustment (${JSON.stringify(wrapDistinctTops)})`); |
| 226 | assert(wrapResult.final.mode === "tail" && wrapResult.final.distance <= 4, |
| 227 | `a real line wrap settles at the physical tail (${wrapResult.final.distance.toFixed(1)}px)`); |
| 228 | |
| 229 | // Reader mode is user-owned: editing a draft while reading upward must not |
| 230 | // reclaim the viewport or emit a compensating tail movement. |
| 231 | await input.fill("existing first line\nexisting second line"); |
| 232 | await waitForTail(page); |
| 233 | const transcript = page.locator(".transcript"); |
| 234 | const transcriptBox = await transcript.boundingBox(); |
| 235 | if (!transcriptBox) throw new Error("transcript has no visible bounding box"); |
| 236 | await page.mouse.move(transcriptBox.x + transcriptBox.width / 2, transcriptBox.y + transcriptBox.height / 2); |
| 237 | await page.mouse.wheel(0, -600); |
| 238 | await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "reader", undefined, { timeout: 5_000 }); |
| 239 | await page.waitForTimeout(150); |
| 240 | await transcript.focus(); |
| 241 | const beforeKey = await transcript.evaluate(element => element.scrollTop); |
| 242 | await page.keyboard.press("PageUp"); |
| 243 | await page.waitForFunction(before => document.querySelector(".transcript").scrollTop < before - 20, beforeKey); |
| 244 | assert(await transcript.getAttribute("data-scroll-mode") === "reader", "native PageUp retains reader ownership"); |
| 245 | const touch = await page.context().newCDPSession(page); |
| 246 | await touch.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); |
| 247 | const x = transcriptBox.x + transcriptBox.width / 2, y = transcriptBox.y + transcriptBox.height / 3; |
| 248 | const beforeTouch = await transcript.evaluate(element => element.scrollTop); |
| 249 | await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); |
| 250 | for (let step = 1; step <= 6; step++) { |
| 251 | await touch.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + step * 20 }] }); |
| 252 | await page.waitForTimeout(20); |
| 253 | } |
| 254 | await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); |
| 255 | await page.waitForFunction(before => document.querySelector(".transcript").scrollTop < before - 20, beforeTouch); |
| 256 | await page.waitForTimeout(500); |
| 257 | assert(await transcript.getAttribute("data-scroll-mode") === "reader", "browser touch scrolling retains reader ownership"); |
| 258 | await touch.send("Emulation.setTouchEmulationEnabled", { enabled: false }); await touch.detach(); |
| 259 | await input.focus(); |
| 260 | const readerBaseline = await resetScrollProbe(page); |
| 261 | await input.type("z"); |
| 262 | await input.press("Backspace"); |
| 263 | await page.waitForTimeout(250); |
| 264 | const readerResult = await readScrollProbe(page); |
| 265 | const readerDeviation = Math.max(0, ...readerResult.samples.map((sample) => Math.abs(sample.top - readerBaseline.top))); |
| 266 | assert(readerDeviation <= 1, `editing while reading upward preserves scrollTop (${readerDeviation.toFixed(1)}px deviation)`); |
| 267 | assert(readerResult.final.mode === "reader", "editing while reading upward preserves manual reader ownership"); |
| 268 | |
| 269 | // A saved manual composer height uses the same off-flow mirror. Once the |
| 270 | // resize itself settles, ordinary edits must leave reader geometry untouched. |
| 271 | await clickIfVisible(page, ".chat-to-bottom"); |
| 272 | await waitForTail(page); |
| 273 | const resizeHandle = page.locator(".composer-resize-handle"); |
| 274 | await resizeHandle.focus(); |
| 275 | await resizeHandle.press("ArrowUp"); |
| 276 | await resizeHandle.press("ArrowUp"); |
| 277 | await waitForTail(page); |
| 278 | assert(await page.locator(".composer-card--resized").count() === 1, "fixture enters user-resized composer mode"); |
| 279 | await input.focus(); |
| 280 | const resizedBaseline = await resetScrollProbe(page); |
| 281 | await input.type("q"); |
| 282 | await input.press("Backspace"); |
| 283 | await page.waitForTimeout(250); |
| 284 | const resizedResult = await readScrollProbe(page); |
| 285 | const resizedReverse = resizedBaseline.top - Math.min(resizedBaseline.top, ...resizedResult.samples.map((sample) => sample.top)); |
| 286 | assert(resizedReverse <= 1, `editing a user-resized composer does not reverse scrollTop (${resizedReverse.toFixed(1)}px)`); |
| 287 | assert(resizedResult.samples.every((sample) => ( |
| 288 | Math.abs(sample.height - resizedBaseline.height) <= 0.5 && sample.clientHeight === resizedBaseline.clientHeight |
| 289 | )), "editing a user-resized composer keeps transcript geometry stable"); |
| 290 | |
| 291 | // Re-enter autosize mode and replay Chromium's IME event order. The |
| 292 | // provisional value is measured by the mirror without collapsing the live |
| 293 | // textarea in the layout flow. |
| 294 | await resizeHandle.dblclick(); |
| 295 | await waitForTail(page); |
| 296 | await input.focus(); |
| 297 | const imeBaseline = await resetScrollProbe(page); |
| 298 | await input.evaluate((element) => { |
| 299 | element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" })); |
| 300 | const nextValue = `${element.value}你`; |
| 301 | const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; |
| 302 | setter?.call(element, nextValue); |
| 303 | element.setSelectionRange(nextValue.length, nextValue.length); |
| 304 | element.dispatchEvent(new InputEvent("input", { |
| 305 | bubbles: true, |
| 306 | data: "你", |
| 307 | inputType: "insertCompositionText", |
| 308 | isComposing: true, |
| 309 | })); |
| 310 | }); |
| 311 | await page.waitForTimeout(80); |
| 312 | await input.evaluate((element) => { |
| 313 | element.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "你" })); |
| 314 | }); |
| 315 | await page.waitForTimeout(300); |
| 316 | const imeResult = await readScrollProbe(page); |
| 317 | const imeReverse = imeBaseline.top - Math.min(imeBaseline.top, ...imeResult.samples.map((sample) => sample.top)); |
| 318 | assert(imeReverse <= 1, `IME composition does not reverse scrollTop (${imeReverse.toFixed(1)}px)`); |
| 319 | assert(imeResult.final.mode === "tail" && imeResult.final.distance <= 4, |
| 320 | `IME composition finishes at the physical tail (${imeResult.final.distance.toFixed(1)}px)`); |
| 321 | assert(pageErrors.length === 0, `browser reports no page errors (${pageErrors.length})`); |
| 322 | } finally { |
| 323 | if (browser) await browser.close(); |
| 324 | await server.close(); |
| 325 | } |
| 326 |