Files
orca/tests/e2e/diff-text-selection.ts
T
Neil 8e6ba76f27 test(diff): close the last detach window before measuring selection glyphs
The settle loop still had an rAF after it -- the horizontal scroll awaited a frame before point()
measured, so the highlight upgrade could detach the glyphs in that window and reproduce the same
bogus clipped-or-covered error. Nothing scrolls smoothly here (no scroll-behavior: smooth in our
CSS or Pierre's) and getBoundingClientRect forces sync layout, so the await bought nothing: drop
it and keep everything from the settle loop to measurement synchronous.

point() now re-asserts liveness and reports detachment as itself rather than as pane geometry,
which is what sent three rounds of fixes chasing viewport width. contentEl() is no longer
non-null-asserted -- the deref sat inside the JSON.stringify building the diagnostic, so a
container swap would have replaced the numbers with a TypeError -- and the never-settled error
now names the synthetic-newline endpoint case too.
2026-09-11 03:35:24 -07:00

146 lines
6.9 KiB
TypeScript

import type { Locator } from '@playwright/test'
export async function diffTextSelectionPoints(code: Locator, text: string) {
return code.evaluate(async (code, text) => {
// Why re-queried: if the upgrade replaces the container rather than its rows, a cached
// reference walks a detached tree and never matches again.
const contentEl = () => code.querySelector('[data-content]')
const collect = () => {
const found: Text[] = []
for (const row of contentEl()?.querySelectorAll('[data-line]') ?? []) {
if (found.length > 0 && !found.at(-1)!.data.endsWith('\n')) {
// Pierre renders line breaks as separate rows rather than text nodes.
found.push(document.createTextNode('\n'))
}
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
found.push(walker.currentNode as Text)
}
}
return found
}
let nodes = collect()
const contents = nodes.map((node) => node.data).join('')
const start = contents.indexOf(text)
if (start === -1) {
throw new Error('Selection text is absent from the rendered diff')
}
const position = (offset: number) => {
for (const node of nodes) {
if (offset < node.length) {
return { node, offset }
}
offset -= node.length
}
throw new Error('Selection endpoint is outside the rendered diff')
}
const glyph = (offset: number) => {
const point = position(offset)
const range = document.createRange()
range.setStart(point.node, point.offset)
range.setEnd(point.node, point.offset + 1)
return range
}
let first = glyph(start)
let last = glyph(start + text.length - 1)
// Why: a short viewport (CI runs smaller than a dev window) can leave the target rows below
// the fold, so the computed points hit whatever covers them instead of the diff.
const rowOf = (range: Range) =>
(range.startContainer.parentElement ?? null)?.closest('[data-line]') ?? null
for (const row of [rowOf(last), rowOf(first)]) {
// inline: 'nearest' — the default would scroll the row start under the sticky line-number column.
row?.scrollIntoView({ block: 'center', inline: 'nearest' })
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
// Why after the scroll waits and not before them: the highlight upgrade replaces a row's
// nodes at an arbitrary frame after first paint. Checking before these awaits passes with
// fresh nodes, and the upgrade then detaches them during the very frames above, so every
// later measurement reads an all-zero rect.
let settled = false
for (let attempt = 0; attempt < 60; attempt += 1) {
if (
first.getBoundingClientRect().width > 0 &&
last.getBoundingClientRect().width > 0 &&
code.contains(first.startContainer) &&
code.contains(last.startContainer)
) {
settled = true
break
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
nodes = collect()
const reindexed = nodes
.map((node) => node.data)
.join('')
.indexOf(text)
if (reindexed === -1) {
continue
}
first = glyph(reindexed)
last = glyph(reindexed + text.length - 1)
}
if (!settled) {
// Why named: otherwise this surfaces below as a clipping error reporting a zero-width
// glyph, which misattributes a detached node to pane geometry.
throw new Error(
`Selection glyphs never settled for ${JSON.stringify(text)}: either the highlight upgrade ` +
'kept replacing the rows, or the text stopped matching (row unmounted by scrolling, or ' +
'an endpoint landed on a synthetic newline node, which is never attached).'
)
}
const viewport = code.getBoundingClientRect()
const left = Math.min(first.getBoundingClientRect().left, last.getBoundingClientRect().left)
const right = Math.max(first.getBoundingClientRect().right, last.getBoundingClientRect().right)
// Why measured and not a constant: the line-number column is sticky, so centering the glyph
// in a narrow pane slides it under the gutter and every point hit-tests as a line number.
// Why a filtered max and not the first: a side-by-side pane stacks two sticky number columns,
// so the first alone leaves the glyph under the second -- but an unfiltered max picks up
// number cells scrolled far to the right and overshoots.
const gutterRight = [...code.querySelectorAll('[data-line-number-content]')].reduce(
(widest, node) => {
const box = node.getBoundingClientRect()
return box.right < viewport.left + viewport.width / 2 ? Math.max(widest, box.right) : widest
},
0
)
const inset = Math.max(24, gutterRight > 0 ? gutterRight - viewport.left + 8 : 0)
// No await after this point: nothing here scrolls smoothly (no `scroll-behavior: smooth` in
// our CSS or Pierre's), and getBoundingClientRect forces sync layout. A frame yield here would
// reopen the window for the highlight upgrade to detach the glyphs just before measuring.
code.scrollLeft += left - viewport.left - Math.max(inset, (viewport.width - (right - left)) / 2)
const point = (range: Range, end: boolean) => {
const rect = range.getBoundingClientRect()
if (rect.width === 0 || !code.contains(range.startContainer)) {
throw new Error(
'Selection glyph was detached before measurement: the highlight upgrade replaced the ' +
'row nodes. This is not a geometry problem.'
)
}
const x = end ? rect.right - 0.5 : rect.left + 0.5
const y = rect.top + rect.height / 2
const root = code.getRootNode() as ShadowRoot
const hit = root.elementFromPoint(x, y)
if (!hit || !contentEl()?.contains(hit) || document.elementFromPoint(x, y) !== root.host) {
// Why the detail: this only reproduces on displays narrower than a dev machine, so the
// numbers have to come back from the runner rather than be guessed at locally.
const pane = code.getBoundingClientRect()
throw new Error(
`Selection endpoint is clipped or covered by another diff pane: ${JSON.stringify({
point: { x: Math.round(x), y: Math.round(y), isEnd: end },
glyph: { left: Math.round(rect.left), right: Math.round(rect.right) },
pane: { left: Math.round(pane.left), width: Math.round(pane.width) },
selectionWidth: Math.round(right - left),
inset: Math.round(inset),
scrollLeft: Math.round(code.scrollLeft),
window: { width: window.innerWidth, height: window.innerHeight },
hit: hit ? hit.tagName : null,
hitInContent: hit ? (contentEl()?.contains(hit) ?? false) : false
})}`
)
}
return { x, y }
}
return { start: point(first, false), end: point(last, true) }
}, text)
}