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