Files
zeekayandHanzo Dev b055cca39f Read what the page paints: /dev is open and the studio dials api.hanzo.ai
seen() answers whether a reader can actually read a string, which neither
toBeVisible nor toContainText does: the workspace sheet leaves every word
attached while it decides, and the sr-only recipe keeps text in the
accessibility tree on purpose. The two specs use it to assert that a stranger
reaching /dev meets the build surface rather than a blank screen, and that the
studio served as hanzo.ai sends its turn to api.hanzo.ai.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 22:32:54 -07:00

107 lines
4.7 KiB
TypeScript

import { type Page } from '@playwright/test'
/**
* WORDS A READER CAN ACTUALLY READ.
*
* `toBeVisible()` and `toContainText()` both answer about the DOM, and the two
* failures this suite keeps meeting are invisible to that question:
*
* the veil `Orgs` hangs an opaque fixed sheet over the room while it
* decides (`components/workspace/Orgs.tsx`). Every word of
* the room is attached, laid out and reports a box — and the
* reader is looking at a blank screen. A gate for "no
* sign-in wall" that reads presence passes on a page showing
* nothing at all, which is the one outcome it exists to
* catch.
*
* a hidden node the sr-only recipe (1px box, `clip-path: inset(50%)`,
* `overflow: hidden`) keeps its text in the accessibility
* tree on purpose. It has a bounding box, it has client
* rects, and nobody sees it.
*
* So the measurement is the TEXT's own geometry plus a hit test. A Range over
* the matched characters gives the rects the browser painted the glyphs into —
* not the element's box, which is the thing both failures above lie with — and
* `elementFromPoint` at those rects says whether anything else is on top or
* whether an ancestor clipped them away. Text under the veil fails the probe;
* text in a 1px clipped box fails it; text scrolled out of a scroller fails it
* until it is brought into view, which is what a reader would do, so `seen`
* does that once before deciding.
*
* Whitespace is not part of the subject: the phrase is matched with `\s+`
* between its words, against the raw node data, so the offsets handed to the
* Range are exact and a line break inside a sentence does not hide it.
*/
export async function seen(page: Page, phrase: string, scope = 'body'): Promise<number> {
return page.evaluate(
({ phrase, scope }) => {
const root = document.querySelector(scope)
if (!root) return 0
const pattern = new RegExp(
phrase
.trim()
.split(/\s+/)
.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s+'),
)
/** A style that removes a node from sight without removing its box. */
const dressed = (node: Node): boolean => {
for (let el = node.parentElement; el; el = el.parentElement) {
const s = getComputedStyle(el)
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return false
if (Number(s.opacity) === 0) return false
}
return true
}
/**
* Something else is painted where these glyphs are — the veil, a clip, a
* modal. The text's own element answering (or an ancestor of it, which is
* what `pointer-events: none` on the run of text resolves to) is the only
* pass.
*/
const onTop = (rect: DOMRect, owner: Element): boolean => {
const ys = [rect.top + rect.height / 2]
const xs = [rect.left + rect.width * 0.1, rect.left + rect.width / 2, rect.left + rect.width * 0.9]
for (const y of ys) {
if (y < 0 || y > innerHeight) continue
for (const x of xs) {
if (x < 0 || x > innerWidth) continue
const hit = document.elementFromPoint(x, y)
if (hit && (hit === owner || hit.contains(owner) || owner.contains(hit))) return true
}
}
return false
}
const painted = (node: Text, at: number, length: number, retry: boolean): boolean => {
const owner = node.parentElement
if (!owner || !dressed(node)) return false
const range = document.createRange()
range.setStart(node, at)
range.setEnd(node, at + length)
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5)
if (rects.length === 0) return false
if (rects.some((r) => onTop(r, owner))) return true
// Out of the window is not out of reach: bring it in the way a reader
// would, then ask once more. A second failure is a real one.
if (!retry) return false
owner.scrollIntoView({ block: 'center', inline: 'nearest' })
return painted(node, at, length, false)
}
let count = 0
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let node = walk.nextNode() as Text | null; node; node = walk.nextNode() as Text | null) {
const found = pattern.exec(node.data)
if (!found) continue
if (painted(node, found.index, found[0].length, true)) count++
}
return count
},
{ phrase, scope },
)
}