mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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]>
107 lines
4.7 KiB
TypeScript
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 },
|
|
)
|
|
}
|