返回 DeepSeek-Reasonix
transcript_native_smoke_contract.js
根目录 / desktop / transcript_native_smoke_contract.js
1 (() => {
2 "use strict";
3
4 const post = (payload) => {
5 const message = JSON.stringify(payload);
6 if (window.chrome?.webview?.postMessage) {
7 window.chrome.webview.postMessage(message);
8 return;
9 }
10 if (window.webkit?.messageHandlers?.reasonixNativeSmoke) {
11 window.webkit.messageHandlers.reasonixNativeSmoke.postMessage(message);
12 }
13 };
14
15 const waitFor = (predicate, timeout = 30000) => new Promise((resolve, reject) => {
16 const startedAt = performance.now();
17 const sample = () => {
18 const value = predicate();
19 if (value) {
20 resolve(value);
21 return;
22 }
23 if (performance.now() - startedAt >= timeout) {
24 reject(new Error("native transcript fixture timed out"));
25 return;
26 }
27 requestAnimationFrame(sample);
28 };
29 sample();
30 });
31
32 const state = {
33 transcript: null,
34 frames: [],
35 active: false,
36 growthTimer: 0,
37 growthTicks: 0,
38 growthSurface: null,
39 initialDistance: 0,
40 phase: "waiting-topic",
41 writes: [],
42 wheelEvents: 0,
43 lastWheelAt: Number.NEGATIVE_INFINITY,
44 wheelDelta: 0,
45 wheelInsideTranscript: 0,
46 wheelMaxDelta: 0,
47 programmaticReaderWrites: 0,
48 staleGenerationWrites: 0,
49 reachableTailResidual: 0,
50 composer: {
51 enabled: false,
52 active: false,
53 input: null,
54 initialValue: "",
55 baseline: null,
56 samples: [],
57 observer: null,
58 onScroll: null,
59 resolve: null,
60 reject: null,
61 result: null,
62 },
63 };
64 window.__reasonixNativeTranscriptSmokeState = state;
65 window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => {
66 const previousWrite = state.writes.at(-1);
67 state.writes.push(write);
68 if (state.writes.length > 80) state.writes.shift();
69 const accepted = !write.rejectedReason && (write.outcome === "accepted" || write.outcome === "native-clamp");
70 const surfaceGeneration = Number.parseInt(state.transcript?.dataset.transcriptGeneration ?? "0", 10);
71 if (accepted && state.active && performance.now() - state.lastWheelAt <= 250) {
72 state.programmaticReaderWrites += 1;
73 }
74 if (accepted && Number.isFinite(surfaceGeneration) && surfaceGeneration > 0 && write.generation !== surfaceGeneration) {
75 state.staleGenerationWrites += 1;
76 }
77 if (write.owner !== "tail-follow" || !accepted || !Number.isFinite(write.acceptedOffset)) return;
78 const repeatedClamp = previousWrite?.owner === "tail-follow"
79 && !previousWrite.rejectedReason
80 && Math.abs(previousWrite.acceptedOffset - write.acceptedOffset) <= 1
81 && Math.abs(previousWrite.scrollHeight - write.scrollHeight) <= 1
82 && Math.abs(previousWrite.clientHeight - write.clientHeight) <= 1
83 && Math.abs(previousWrite.scrollTop - write.scrollTop) <= 0.5;
84 const reportedResidual = write.scrollHeight - write.clientHeight - write.scrollTop;
85 if (repeatedClamp && reportedResidual > 4 && reportedResidual <= 64) {
86 state.reachableTailResidual = reportedResidual;
87 }
88 requestAnimationFrame(() => {
89 const element = state.transcript;
90 if (!(element instanceof HTMLElement)) return;
91 const theoreticalTop = Math.max(0, element.scrollHeight - element.clientHeight);
92 const residual = theoreticalTop - element.scrollTop;
93 const sameGeometry = Math.abs(element.scrollHeight - write.scrollHeight) <= 1
94 && Math.abs(element.clientHeight - write.clientHeight) <= 1;
95 if (
96 sameGeometry
97 && Math.abs(write.acceptedOffset - write.scrollTop) <= 1
98 && Math.abs(element.scrollTop - write.scrollTop) <= 0.5
99 && residual > 4
100 && residual <= 64
101 ) {
102 state.reachableTailResidual = residual;
103 }
104 });
105 };
106 window.addEventListener("wheel", (event) => {
107 if (!state.active) return;
108 state.lastWheelAt = performance.now();
109 state.wheelEvents += 1;
110 state.wheelDelta += event.deltaY;
111 if (event.target instanceof Node && state.transcript?.contains(event.target)) state.wheelInsideTranscript += 1;
112 state.wheelMaxDelta = Math.max(state.wheelMaxDelta, Math.abs(event.deltaY));
113 }, { capture: true, passive: true });
114
115 // WebView2 can expose a stable native scrollHeight with a small terminal
116 // range that scrollTop cannot reach. Accept it only after repeated writer
117 // observations prove the clamp at unchanged geometry.
118 const tailDistance = (element) => {
119 const theoreticalTop = Math.max(0, element.scrollHeight - element.clientHeight);
120 const observedTop = theoreticalTop - state.reachableTailResidual;
121 if (element.scrollTop <= observedTop + 4) return observedTop - element.scrollTop;
122 state.reachableTailResidual = 0;
123 return theoreticalTop - element.scrollTop;
124 };
125
126 const visibleRows = (element) => {
127 const viewport = element.getBoundingClientRect();
128 return [...element.querySelectorAll(".transcript__row")].filter((row) => {
129 const rect = row.getBoundingClientRect();
130 return rect.bottom > viewport.top && rect.top < viewport.bottom;
131 });
132 };
133
134 const outerReaderPoint = (element) => {
135 const viewport = element.getBoundingClientRect();
136 for (const row of visibleRows(element)) {
137 const rect = row.getBoundingClientRect();
138 const visibleTop = Math.max(viewport.top, rect.top);
139 const visibleBottom = Math.min(viewport.bottom, rect.bottom);
140 if (visibleBottom - visibleTop < 2) continue;
141 const y = visibleTop + (visibleBottom - visibleTop) / 2;
142 // Match the browser gate: row padding is owned by Transcript, while
143 // code/table descendants may own their own nested scrollports.
144 for (const x of [rect.left + 16, rect.right - 16]) {
145 if (document.elementFromPoint(x, y) === row) {
146 return { x: Math.round(x), y: Math.round(y) };
147 }
148 }
149 }
150 return null;
151 };
152
153 const scheduleSample = () => {
154 requestAnimationFrame(() => window.setTimeout(sample, 0));
155 };
156
157 const sample = () => {
158 if (!state.active || !(state.transcript instanceof HTMLElement)) return;
159 const element = state.transcript;
160 const viewport = element.getBoundingClientRect();
161 const rows = visibleRows(element);
162 const projection = element.querySelector(".transcript__projection");
163 const mounted = [...element.querySelectorAll(".transcript__window-item[data-index]")]
164 .map((block) => Number.parseInt(block.dataset.index ?? "", 10))
165 .filter(Number.isFinite);
166 const mountedCount = Number.parseInt(
167 projection?.getAttribute("data-transcript-mounted-blocks") ?? "0",
168 10,
169 );
170 const visible = [...element.querySelectorAll("[data-transcript-block-key]")]
171 .filter((block) => {
172 const rect = block.getBoundingClientRect();
173 return rect.bottom > viewport.top && rect.top < viewport.bottom;
174 }).map((block) => {
175 const item = block.closest(".transcript__window-item[data-index]");
176 return {
177 index: block.dataset.transcriptBlockKey ?? "",
178 itemIndex: Number.parseInt(item?.dataset.index ?? "", 10),
179 top: block.getBoundingClientRect().top - viewport.top,
180 };
181 });
182 const blankGeometry = rows.length === 0 ? {
183 viewportTop: viewport.top,
184 viewportBottom: viewport.bottom,
185 coldTop: element.querySelector(".transcript__window")?.getBoundingClientRect().top ?? null,
186 coldBottom: element.querySelector(".transcript__window")?.getBoundingClientRect().bottom ?? null,
187 items: [...element.querySelectorAll(".transcript__window-item[data-index]")].map((item) => {
188 const rect = item.getBoundingClientRect();
189 const block = item;
190 const blockRect = block?.getBoundingClientRect();
191 return {
192 index: Number.parseInt(item.dataset.index ?? "", 10),
193 modelTop: Number.parseFloat(item.style.top || "0"),
194 top: rect.top - viewport.top,
195 bottom: rect.bottom - viewport.top,
196 height: rect.height,
197 blockTop: blockRect ? blockRect.top - viewport.top : null,
198 blockBottom: blockRect ? blockRect.bottom - viewport.top : null,
199 blockHeight: blockRect?.height ?? null,
200 };
201 }),
202 } : undefined;
203 state.frames.push({
204 top: element.scrollTop,
205 height: element.scrollHeight,
206 occupied: rows.length > 0,
207 mode: element.dataset.scrollMode ?? "missing",
208 readerIntent: element.dataset.transcriptIntent ?? "missing",
209 rangeSource: projection?.getAttribute("data-transcript-range-source") ?? "none",
210 mountedFirst: mounted.length > 0 ? Math.min(...mounted) : null,
211 mountedLast: mounted.length > 0 ? Math.max(...mounted) : null,
212 mountedCount,
213 visible,
214 blankGeometry,
215 });
216 // ResizeObserver is delivered after rAF layout work but before paint.
217 // Sample from the following task so the contract measures the geometry a
218 // native WebView actually painted, not an intermediate pre-observer state.
219 scheduleSample();
220 };
221
222 const growFooter = () => {
223 if (!(state.growthSurface instanceof HTMLElement) || state.growthTicks >= 64) return;
224 state.growthSurface.style.height = `${Number.parseFloat(state.growthSurface.style.height || "0") + 2}px`;
225 state.growthTicks += 1;
226 };
227
228 const waitForStableViewport = (element, requiredFrames = 8, timeout = 10000) => new Promise((resolve, reject) => {
229 const startedAt = performance.now();
230 let previous = null;
231 let stableFrames = 0;
232 const recent = [];
233 const sample = () => {
234 const current = { top: element.scrollTop, height: element.scrollHeight, clientHeight: element.clientHeight };
235 recent.push(current);
236 if (recent.length > 12) recent.shift();
237 const stable = previous
238 && Math.abs(current.top - previous.top) <= 1
239 && Math.abs(current.height - previous.height) <= 1
240 && current.clientHeight === previous.clientHeight
241 && visibleRows(element).length > 0;
242 stableFrames = stable ? stableFrames + 1 : 0;
243 previous = current;
244 if (stableFrames >= requiredFrames) {
245 resolve();
246 return;
247 }
248 if (performance.now() - startedAt >= timeout) {
249 reject(new Error(`native transcript viewport did not stabilize: ${JSON.stringify({
250 stableFrames,
251 recent,
252 visibleRows: visibleRows(element).length,
253 mode: element.dataset.scrollMode ?? "missing",
254 rows: element.dataset.transcriptRowCount ?? "0",
255 })}`));
256 return;
257 }
258 requestAnimationFrame(sample);
259 };
260 requestAnimationFrame(sample);
261 });
262
263 const waitForStableTail = (element, requiredFrames = 2, timeout = 5000) => new Promise((resolve) => {
264 const startedAt = performance.now();
265 let stableFrames = 0;
266 const sample = () => {
267 const stable = element.dataset.scrollMode === "tail-follow"
268 && tailDistance(element) <= 4
269 && visibleRows(element).length > 0;
270 stableFrames = stable ? stableFrames + 1 : 0;
271 if (stableFrames >= requiredFrames || performance.now() - startedAt >= timeout) {
272 resolve();
273 return;
274 }
275 requestAnimationFrame(sample);
276 };
277 requestAnimationFrame(sample);
278 });
279
280 const settleFrames = (count = 4) => new Promise((resolve) => {
281 const settle = () => {
282 count -= 1;
283 if (count <= 0) resolve();
284 else requestAnimationFrame(settle);
285 };
286 requestAnimationFrame(settle);
287 });
288
289 const setNativeTextareaValue = (input, value) => {
290 const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
291 setter?.call(input, value);
292 input.dispatchEvent(new InputEvent("input", {
293 bubbles: true,
294 data: value,
295 inputType: "insertText",
296 }));
297 input.setSelectionRange(value.length, value.length);
298 };
299
300 const sampleComposer = (source) => {
301 const composer = state.composer;
302 const element = state.transcript;
303 if (!composer.active || !(element instanceof HTMLElement)) return;
304 composer.samples.push({
305 source,
306 top: element.scrollTop,
307 height: element.scrollHeight,
308 clientHeight: element.clientHeight,
309 distance: tailDistance(element),
310 });
311 };
312
313 const prepareNativeComposer = async (element) => {
314 if (new URLSearchParams(window.location.search).get("nativeComposer") !== "1") return;
315 state.transcript = element;
316 state.phase = "preparing-native-composer";
317 const input = await waitFor(() => document.querySelector(
318 "textarea.composer__input:not(.composer__input--measure)",
319 ), 10000);
320 const initialValue = "existing first line\nexisting second line";
321 setNativeTextareaValue(input, initialValue);
322 input.focus();
323 await waitFor(() => input.value === initialValue && input.getBoundingClientRect().height > 32, 10000);
324 await document.fonts.ready;
325
326 state.composer.input = input;
327 state.composer.initialValue = initialValue;
328 };
329
330 const runNativeComposer = async (element) => {
331 if (new URLSearchParams(window.location.search).get("nativeComposer") !== "1") return;
332 state.transcript = element;
333 state.phase = "waiting-native-composer";
334 const composer = state.composer;
335 const input = composer.input;
336 if (!(input instanceof HTMLTextAreaElement) || input.value !== composer.initialValue) {
337 throw new Error("native composer draft was not prepared before loading history");
338 }
339 input.focus();
340 await waitForStableTail(element, 2, 10000);
341 await waitForStableViewport(element, 12, 15000);
342 const initialDistance = tailDistance(element);
343 if (element.dataset.scrollMode !== "tail-follow" || initialDistance > 4) {
344 throw new Error(`native composer did not start at a stable tail: ${describeTranscriptState(element)}`);
345 }
346
347 composer.enabled = true;
348 composer.active = true;
349 composer.input = input;
350 composer.samples = [];
351 composer.result = null;
352 composer.baseline = {
353 top: element.scrollTop,
354 height: element.scrollHeight,
355 clientHeight: element.clientHeight,
356 inputHeight: input.getBoundingClientRect().height,
357 initialValue: composer.initialValue,
358 };
359 composer.onScroll = () => sampleComposer("scroll");
360 element.addEventListener("scroll", composer.onScroll, { passive: true });
361 composer.observer = new ResizeObserver(() => sampleComposer("resize"));
362 composer.observer.observe(element);
363 sampleComposer("baseline");
364 const sampleFrame = () => {
365 if (!composer.active) return;
366 sampleComposer("frame");
367 requestAnimationFrame(sampleFrame);
368 };
369 requestAnimationFrame(sampleFrame);
370
371 const completion = new Promise((resolve, reject) => {
372 composer.resolve = resolve;
373 composer.reject = reject;
374 });
375 const rect = input.getBoundingClientRect();
376 state.phase = "native-composer-ready";
377 post({
378 type: "composer-ready",
379 point: { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) },
380 });
381 await completion;
382 state.phase = "native-composer-complete";
383 };
384
385 const finishComposer = async () => {
386 const composer = state.composer;
387 const element = state.transcript;
388 if (!composer.active || !(element instanceof HTMLElement) || !(composer.input instanceof HTMLTextAreaElement)) {
389 return composer.result;
390 }
391 try {
392 await settleFrames(8);
393 sampleComposer("final");
394 composer.active = false;
395 composer.observer?.disconnect();
396 if (composer.onScroll) element.removeEventListener("scroll", composer.onScroll);
397 const baseline = composer.baseline;
398 const minTop = Math.min(baseline.top, ...composer.samples.map((sample) => sample.top));
399 const geometryChanges = composer.samples.filter((sample) => (
400 Math.abs(sample.height - baseline.height) > 0.5 || sample.clientHeight !== baseline.clientHeight
401 )).length;
402 const finalDistance = tailDistance(element);
403 const finalGeometry = {
404 top: element.scrollTop,
405 height: element.scrollHeight,
406 clientHeight: element.clientHeight,
407 };
408 const changedSamples = composer.samples.filter((sample) => (
409 Math.abs(sample.height - baseline.height) > 0.5 || sample.clientHeight !== baseline.clientHeight
410 ));
411 composer.result = {
412 passed: composer.samples.length >= 8
413 && baseline.inputHeight > 32
414 && composer.input.value === baseline.initialValue
415 && baseline.top - minTop <= 1
416 && geometryChanges === 0
417 && element.dataset.scrollMode === "tail-follow"
418 && finalDistance <= 4,
419 samples: composer.samples.length,
420 maxReverse: baseline.top - minTop,
421 geometryChanges,
422 finalDistance,
423 inputHeight: baseline.inputHeight,
424 finalInputHeight: composer.input.getBoundingClientRect().height,
425 finalValueMatches: composer.input.value === baseline.initialValue,
426 baseline: {
427 top: baseline.top,
428 height: baseline.height,
429 clientHeight: baseline.clientHeight,
430 },
431 finalGeometry,
432 changedSamples: changedSamples.length > 0
433 ? [changedSamples[0], changedSamples[changedSamples.length - 1]]
434 : [],
435 };
436 if (!composer.result.passed) {
437 throw new Error(`native composer stability failed: ${JSON.stringify(composer.result)}`);
438 }
439 setNativeTextareaValue(composer.input, "");
440 await waitForStableTail(element, 2, 10000);
441 await waitForStableViewport(element, 2, 10000);
442 composer.resolve?.();
443 } catch (error) {
444 composer.reject?.(error);
445 }
446 return composer.result;
447 };
448
449 // A bare "timed out" cannot distinguish a missing rail, a stuck jump mask,
450 // or a jump-bottom button that never appears on a hosted runner, so every
451 // internal gate reports the live transcript state when it fails.
452 const describeTranscriptState = (element) => {
453 const overlay = document.querySelector(".transcript-navigation-overlay");
454 const loadedTurns = [...document.querySelectorAll(".jump-item[data-loaded='true']")]
455 .map((marker) => Number(marker.getAttribute("data-turn")))
456 .filter(Number.isFinite);
457 return JSON.stringify({
458 rows: Number.parseInt(element?.dataset.transcriptRowCount ?? "0", 10),
459 markers: document.querySelectorAll(".jump-item").length,
460 earliestTurn: loadedTurns.length > 0 ? Math.min(...loadedTurns) : null,
461 overlayPhase: overlay ? overlay.getAttribute("data-question-jump-phase") ?? "present" : null,
462 shellBusy: document.querySelector(".transcript-shell")?.getAttribute("aria-busy") ?? null,
463 mode: element?.dataset.scrollMode ?? "missing",
464 top: element ? Math.round(element.scrollTop) : null,
465 height: element?.scrollHeight ?? null,
466 clientHeight: element?.clientHeight ?? null,
467 offsetHeight: element?.offsetHeight ?? null,
468 rectHeight: element ? element.getBoundingClientRect().height : null,
469 reachableTailResidual: state.reachableTailResidual,
470 bottomDistance: element
471 ? Math.round(tailDistance(element))
472 : null,
473 recentWrites: state.writes.slice(-8),
474 });
475 };
476
477 const loadHistoryRows = async (element, minimumRows) => {
478 const rowCount = () => Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10);
479 if (rowCount() >= minimumRows) return;
480 const earliestLoadedTurn = () => {
481 const turns = [...document.querySelectorAll(".jump-item[data-loaded='true']")]
482 .map((marker) => Number(marker.getAttribute("data-turn")))
483 .filter(Number.isFinite);
484 return turns.length > 0 ? Math.min(...turns) : null;
485 };
486 const rail = await waitFor(() => document.querySelector(".jump-scroll"), 10000)
487 .catch(() => {
488 throw new Error(`native transcript fixture question rail unavailable: ${describeTranscriptState(element)}`);
489 });
490 // Target progressively earlier questions through the real navigation UI.
491 // A midpoint request usually supplies the 400+ variable-height rows the
492 // smoke needs without forcing every archived turn into its first geometry
493 // pass at once.
494 for (const ratio of [0.65, 0.5, 0.35, 0.2, 0]) {
495 if (rowCount() >= minimumRows) return;
496 const beforeRows = rowCount();
497 const beforeTurn = earliestLoadedTurn();
498 const beforeJumpTransaction = Math.max(0, ...state.writes
499 .filter((write) => write.owner === "question-jump")
500 .map((write) => Number(write.transactionId ?? write.transaction ?? 0)));
501 const rect = rail.getBoundingClientRect();
502 rail.dispatchEvent(new MouseEvent("mousedown", {
503 button: 0,
504 clientX: rect.left + rect.width / 2,
505 clientY: rect.top + rect.height * ratio,
506 bubbles: true,
507 cancelable: true,
508 }));
509 // Data may commit before React paints the loading mask, so absence of
510 // the mask is not a completion signal. Wait for the kernel's physical
511 // landing transaction, then require the UI mask to be gone.
512 await waitFor(() => state.writes.some((write) => (
513 write.owner === "question-jump"
514 && Number(write.transactionId ?? write.transaction ?? 0) > beforeJumpTransaction
515 && ["accepted", "native-clamp", "no-op"].includes(write.outcome)
516 )), 30000)
517 .catch(() => {
518 throw new Error(`native transcript fixture question jump did not land at ratio ${ratio}: ${describeTranscriptState(element)}`);
519 });
520 await waitFor(() => !document.querySelector(".transcript-navigation-overlay"), 15000)
521 .catch(() => {
522 throw new Error(`native transcript fixture question jump surface stuck at ratio ${ratio}: ${describeTranscriptState(element)}`);
523 });
524 // A fixed-size history window keeps the mounted row count constant when
525 // an earlier page arrives, and clicking an already-loaded turn changes
526 // nothing. Accept an earlier first loaded turn or genuine row growth; a
527 // no-op click falls through to the next, earlier ratio instead of
528 // stalling the fixture on one signal.
529 await waitFor(() => {
530 const earliest = earliestLoadedTurn();
531 return rowCount() > beforeRows
532 || (earliest !== null && (beforeTurn === null || earliest < beforeTurn));
533 }, 8000).catch(() => {});
534 }
535 if (rowCount() >= minimumRows) return;
536 throw new Error(`native transcript fixture could not load ${minimumRows} rows (rows=${rowCount()} earliestTurn=${earliestLoadedTurn()})`);
537 };
538
539 const start = async () => {
540 const topic = await waitFor(() => [...document.querySelectorAll(".project-tree__topic-main")]
541 .find((candidate) => candidate.textContent?.includes("bench:windowed-1000t")));
542 topic.click();
543 state.phase = "waiting-topic-selection";
544 await waitFor(() => (
545 document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:windowed-1000t")
546 && document.querySelector(".transcript")?.textContent?.includes("Windowed turn 1000")
547 ), 30000);
548 state.phase = "waiting-navigation-surface";
549 await waitFor(() => !document.querySelector(".transcript-navigation-overlay"), 15000);
550 const element = await waitFor(() => {
551 const candidate = document.querySelector(".transcript");
552 const rows = Number.parseInt(candidate?.dataset.transcriptRowCount ?? "0", 10);
553 return candidate instanceof HTMLElement && rows > 0 && candidate.scrollHeight > candidate.clientHeight
554 ? candidate
555 : null;
556 });
557 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
558 state.phase = "waiting-initial-tail";
559 await waitForStableTail(element, 2, 10000);
560 state.phase = "waiting-initial-stability";
561 // WebView2 can revisit the first block estimates while pending Markdown
562 // resolves. Two painted stable frames are sufficient before the
563 // idempotent history-navigation setup; the loaded 400+ row surface still
564 // has to satisfy the stricter tail and geometry gates below before native
565 // sampling starts.
566 await waitForStableViewport(element, 2, 15000);
567 await prepareNativeComposer(element);
568 state.phase = "loading-targeted-history";
569 await loadHistoryRows(element, 400);
570 // A real history jump owns manual-reader mode and must expose the product's
571 // jump-bottom control. When the initial window already has enough rows,
572 // the control is correctly absent and the product must preserve its
573 // physical tail without a fixture-owned scroll write.
574 let jumpBottom = document.querySelector(".transcript__jump-bottom:not([hidden])");
575 if (!jumpBottom && element.dataset.scrollMode !== "tail-follow") {
576 jumpBottom = await waitFor(() => document.querySelector(".transcript__jump-bottom:not([hidden])"), 10000)
577 .catch(() => null);
578 }
579 if (jumpBottom instanceof HTMLElement) {
580 jumpBottom.click();
581 } else if (element.dataset.scrollMode !== "tail-follow") {
582 throw new Error(`native transcript fixture left manual-reader mode without a jump-bottom control: ${describeTranscriptState(element)}`);
583 }
584 state.phase = "waiting-loaded-tail";
585 await waitForStableTail(element, 2, 10000);
586 if (element.dataset.scrollMode !== "tail-follow"
587 || tailDistance(element) > 4) {
588 throw new Error(`native transcript fixture could not establish the loaded tail: ${describeTranscriptState(element)}`);
589 }
590 // The initial history page can keep settling block estimates after a
591 // couple of visually stable frames on a slower hosted WebView2. Run the
592 // composer regression only after the long loaded surface has held both
593 // its tail and geometry across a stricter painted-frame window, so the
594 // samples isolate keyboard-driven layout from first-load measurement.
595 await waitForStableViewport(element, 12, 15000);
596 await runNativeComposer(element);
597 // Position through the product's indexed question navigator so every
598 // physical write remains owned by TranscriptViewportWriter.
599 const rail = await waitFor(() => document.querySelector(".jump-scroll"), 10000);
600 state.phase = "waiting-reader-stability";
601 for (const ratio of [0.35, 0.2, 0]) {
602 const before = element.scrollTop;
603 const rect = rail.getBoundingClientRect();
604 rail.dispatchEvent(new MouseEvent("mousedown", {
605 button: 0,
606 clientX: rect.left + rect.width / 2,
607 clientY: rect.top + rect.height * ratio,
608 bubbles: true,
609 cancelable: true,
610 }));
611 await waitFor(() => (
612 Math.abs(element.scrollTop - before) > element.clientHeight / 2
613 || document.querySelector(".transcript-navigation-overlay")
614 ), 10000);
615 await waitFor(() => !document.querySelector(".transcript-navigation-overlay"), 15000);
616 await waitForStableViewport(element);
617 state.initialDistance = tailDistance(element);
618 if (state.initialDistance >= element.clientHeight * 2) break;
619 }
620 if (state.initialDistance < element.clientHeight * 2) {
621 throw new Error(`native transcript fixture did not establish a deep reader start (${state.initialDistance}px)`);
622 }
623 // The navigator is programmatic setup only. Claim manual reader ownership
624 // before the host begins the platform-native downward traversal.
625 element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true, cancelable: true }));
626 await waitFor(() => element.dataset.scrollMode === "manual", 5000);
627 state.phase = "waiting-reader-geometry";
628 // Establish the baseline only after the turn window is populated and
629 // bounded. Pending Markdown remains part of the streamed test itself.
630 await waitFor(() => (
631 document.querySelector("[data-transcript-render-mode='windowed']")
632 && Number(document.querySelector(".transcript__projection")?.getAttribute("data-transcript-mounted-blocks")) > 0
633 && Number(document.querySelector(".transcript__projection")?.getAttribute("data-transcript-mounted-blocks")) <= 40
634 ), 30000);
635 await waitForStableViewport(element, 8, 15000);
636 const residentTail = element.querySelector(".transcript__resident-tail");
637 if (!(residentTail instanceof HTMLElement)) throw new Error("native transcript resident tail is unavailable");
638 const growthSurface = document.createElement("div");
639 growthSurface.setAttribute("aria-hidden", "true");
640 growthSurface.style.cssText = "height:0;width:100%;pointer-events:none;";
641 residentTail.append(growthSurface);
642 state.transcript = element;
643 state.frames = [];
644 state.active = true;
645 state.lastWheelAt = Number.NEGATIVE_INFINITY;
646 state.growthTicks = 0;
647 state.growthSurface = growthSurface;
648 state.wheelEvents = 0;
649 state.wheelDelta = 0;
650 state.wheelInsideTranscript = 0;
651 state.wheelMaxDelta = 0;
652 state.programmaticReaderWrites = 0;
653 state.staleGenerationWrites = 0;
654 state.phase = "ready";
655 state.growthTimer = window.setInterval(growFooter, 16);
656 scheduleSample();
657 const point = outerReaderPoint(element);
658 if (!point) throw new Error("native transcript outer reader target is unavailable");
659 post({
660 type: "ready",
661 rows: Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10),
662 top: element.scrollTop,
663 point,
664 });
665 };
666
667 const finish = async () => {
668 window.clearInterval(state.growthTimer);
669 const element = state.transcript;
670 if (element instanceof HTMLElement) await waitForStableTail(element);
671 state.active = false;
672 const frames = state.frames;
673 let maxReverse = 0;
674 let rawMaxReverse = 0;
675 let worstReverse = null;
676 for (let index = 1; index < frames.length; index += 1) {
677 const previous = frames[index - 1];
678 const current = frames[index];
679 const rawReverse = previous.top - current.top;
680 rawMaxReverse = Math.max(rawMaxReverse, rawReverse);
681 const currentTops = new Map(current.visible.map((row) => [row.index, row.top]));
682 const visibleDeltas = previous.visible
683 .filter((row) => row.index && currentTops.has(row.index))
684 .map((row) => currentTops.get(row.index) - row.top)
685 .sort((left, right) => left - right);
686 // scrollTop can decrease when a measured extent above the viewport
687 // contracts even though the same rows remain visually stationary. The
688 // user-visible reverse displacement is the median screen movement of
689 // rows painted in both adjacent frames; raw scrollTop remains attached
690 // to the failure record for range diagnostics.
691 const reverse = visibleDeltas.length > 0
692 ? visibleDeltas[Math.floor(visibleDeltas.length / 2)]
693 : rawReverse;
694 if (reverse > maxReverse) {
695 maxReverse = reverse;
696 worstReverse = { previous, current, rawReverse, commonRows: visibleDeltas.length };
697 }
698 }
699 const firstTop = frames[0]?.top ?? 0;
700 const lastTop = frames.at(-1)?.top ?? firstTop;
701 const blankFrames = frames.filter((frame) => !frame.occupied);
702 const distance = element instanceof HTMLElement
703 ? tailDistance(element)
704 : Number.POSITIVE_INFINITY;
705 const viewportRect = element instanceof HTMLElement ? element.getBoundingClientRect() : null;
706 const footerRect = state.growthSurface?.parentElement?.getBoundingClientRect();
707 const initialScrollHeight = frames[0]?.height ?? element?.scrollHeight ?? 0;
708 const minScrollHeight = Math.min(initialScrollHeight, ...frames.map((frame) => frame.height));
709 const maxScrollHeight = Math.max(initialScrollHeight, ...frames.map((frame) => frame.height));
710 const finalScrollHeight = element?.scrollHeight ?? frames.at(-1)?.height ?? 0;
711 const collapseTolerance = Math.max(96, (element?.clientHeight ?? 0) * 0.5);
712 const mountedCoverage = frames.length > 0 ? (frames.length - blankFrames.length) / frames.length : 0;
713 const result = {
714 type: "result",
715 passed: frames.length >= 20
716 && lastTop > firstTop + 96
717 && maxReverse <= 4
718 && frames.every((frame) => frame.occupied)
719 && state.initialDistance >= (element?.clientHeight ?? Number.POSITIVE_INFINITY) * 2
720 && distance <= 4
721 && element?.dataset.scrollMode === "tail-follow"
722 && frames.every((frame) => frame.mountedCount <= 40)
723 && state.programmaticReaderWrites === 0
724 && state.staleGenerationWrites === 0
725 && finalScrollHeight >= maxScrollHeight - collapseTolerance
726 && (!state.composer.enabled || state.composer.result?.passed === true),
727 rows: Number.parseInt(element?.dataset.transcriptRowCount ?? "0", 10),
728 frames: frames.length,
729 firstTop,
730 lastTop,
731 initialDistance: state.initialDistance,
732 maxReverse,
733 rawMaxReverse,
734 worstReverse,
735 occupied: frames.every((frame) => frame.occupied),
736 blankFrames: blankFrames.length,
737 firstBlank: blankFrames[0] ?? null,
738 distance,
739 mode: element?.dataset.scrollMode ?? "missing",
740 growthTicks: state.growthTicks,
741 initialScrollHeight,
742 minScrollHeight,
743 maxScrollHeight,
744 finalScrollHeight,
745 initialScrollTop: firstTop,
746 finalScrollTop: lastTop,
747 totalFrames: frames.length,
748 mountedCoverage,
749 finalBottomDistance: distance,
750 finalMode: element?.dataset.scrollMode ?? "missing",
751 deliveredNativeEvents: state.wheelEvents,
752 wheelEvents: state.wheelEvents,
753 wheelDelta: state.wheelDelta,
754 wheelInsideTranscript: state.wheelInsideTranscript,
755 wheelMaxDelta: state.wheelMaxDelta,
756 programmaticReaderWrites: state.programmaticReaderWrites,
757 staleGenerationWrites: state.staleGenerationWrites,
758 paddingBottom: element instanceof HTMLElement ? Number.parseFloat(getComputedStyle(element).paddingBottom) : null,
759 footerBottomDistance: viewportRect && footerRect ? footerRect.bottom - viewportRect.bottom : null,
760 composerEnabled: state.composer.enabled,
761 composerPassed: state.composer.result?.passed ?? false,
762 composerSamples: state.composer.result?.samples ?? 0,
763 composerMaxReverse: state.composer.result?.maxReverse ?? 0,
764 composerGeometryChanges: state.composer.result?.geometryChanges ?? 0,
765 composerFinalDistance: state.composer.result?.finalDistance ?? 0,
766 composerInputHeight: state.composer.result?.inputHeight ?? 0,
767 composerFinalValueMatches: state.composer.result?.finalValueMatches ?? false,
768 writes: state.writes.slice(-20),
769 };
770 post(result);
771 return result;
772 };
773
774 const finishMicro = () => {
775 window.clearInterval(state.growthTimer);
776 state.active = false;
777 const element = state.transcript;
778 const frames = state.frames;
779 const blankFrames = frames.filter((frame) => !frame.occupied);
780 const heights = frames.map((frame) => frame.height);
781 const result = {
782 type: "result",
783 passed: state.wheelEvents > 0 && frames.length > 0,
784 rows: Number.parseInt(element?.dataset.transcriptRowCount ?? "0", 10),
785 frames: frames.length,
786 totalFrames: frames.length,
787 firstTop: frames[0]?.top ?? 0,
788 lastTop: frames.at(-1)?.top ?? 0,
789 initialScrollTop: frames[0]?.top ?? 0,
790 finalScrollTop: frames.at(-1)?.top ?? 0,
791 initialScrollHeight: heights[0] ?? element?.scrollHeight ?? 0,
792 minScrollHeight: heights.length > 0 ? Math.min(...heights) : element?.scrollHeight ?? 0,
793 maxScrollHeight: heights.length > 0 ? Math.max(...heights) : element?.scrollHeight ?? 0,
794 finalScrollHeight: element?.scrollHeight ?? heights.at(-1) ?? 0,
795 blankFrames: blankFrames.length,
796 mountedCoverage: frames.length > 0 ? (frames.length - blankFrames.length) / frames.length : 0,
797 finalBottomDistance: element instanceof HTMLElement ? tailDistance(element) : 0,
798 finalMode: element?.dataset.scrollMode ?? "missing",
799 deliveredNativeEvents: state.wheelEvents,
800 mode: element?.dataset.scrollMode ?? "missing",
801 occupied: blankFrames.length === 0,
802 };
803 post(result);
804 return result;
805 };
806
807 const reportTail = () => {
808 const element = state.transcript;
809 post({
810 type: "tail-status",
811 distance: element instanceof HTMLElement ? tailDistance(element) : Number.MAX_SAFE_INTEGER,
812 mode: element?.dataset.scrollMode ?? "missing",
813 });
814 };
815
816 window.__reasonixNativeTranscriptSmoke = { start, finish, finishMicro, finishComposer, reportTail };
817 start().catch((error) => {
818 const message = String(error?.message ?? error);
819 post({ type: "error", message: `${message} (${state.phase})`, phase: state.phase });
820 });
821 })();
822
822 lines JAVASCRIPT