返回 DeepSeek-Reasonix
composer-transcript-stability.mjs
根目录 / desktop / frontend / bench / composer-transcript-stability.mjs
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
326 lines Plain Text