Files
booth/scripts/layout-probe.py
T
Vuong Hoang 75dca53483 docs(probe): the probe covers the index only, and says so now
The docstring claimed a no-argument run probes 'the booth index and
every booth linked from it'. main() probes argv[1:] or the default URL
and follows nothing — so a coverage claim that reads as 26 pages has
always been one. A probe that overstates its reach is worse than one
that states a small reach honestly, because this is the instrument
standing in for a class of bug the test suite structurally cannot see.

Also records the zsh trap that hid it: an unquoted $URLS holding twelve
space-separated URLs arrives as ONE argument, and the probe cheerfully
reports '2 page(s)' while covering two.
2026-09-22 00:54:54 -07:00

110 lines
4.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""layout-probe — find controls that render but cannot be clicked.
WHY THIS EXISTS. On 2026-09-21 the operator reported "release button covers
delete button". Both controls were `position:absolute` on the same corner of a
kept card, and `release` was the later sibling, so it painted over the × with
30x22 px of overlap on a 30px button. `elementFromPoint` at the ×'s centre
returned the release form: the × was 100% unclickable from the day it shipped.
Nothing in the test suite could have caught it. The markup was correct, the
route was correct, the CSS was individually valid. OCCLUSION IS A PROPERTY OF
THE RENDERED LAYOUT, and the only instrument that sees it is a browser.
This is the second control shipped inert in two days — the first was a reveal
button whose handler Jinja discarded. Both were reported by the operator, both
would have taken ten seconds to catch by looking at the page.
USAGE
<a python with playwright> scripts/layout-probe.py [URL ...]
Exits 0 if every control is hittable, 1 if any is occluded. No arguments
probes the INDEX ONLY — it does not follow booth links, and the docstring
claimed it did until 2026-09-22. Pass booth URLs explicitly to cover them:
scripts/layout-probe.py http://10.100.10.50:8090/{,b/my-run/}
⚠ In zsh an unquoted `$URLS` does NOT word-split, so a variable holding
several URLs arrives as ONE argument and the probe silently reports
"2 page(s)" while covering two. Use an array and `"${URLS[@]}"`.
"""
import sys
from playwright.sync_api import sync_playwright
DEFAULT = "http://10.100.10.50:8090/"
# Does a click at this element's centre actually reach it?
HIT = """(el) => {
// ⚠ Use getClientRects()[0], NOT getBoundingClientRect(). For an INLINE
// element that WRAPS, the bounding rect is the union of its line boxes and
// its geometric centre can land in the gutter between lines — on the parent,
// not on the element. The third version of this probe reported three zip
// links as OCCLUDED for exactly that reason: long booth names wrapped the
// link, and `elementFromPoint` correctly returned the parent .sub div. Real
// geometry, wrong question. Per-line rects ask the right one.
const rects = el.getClientRects();
const r = rects.length ? rects[0] : el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return 'ZERO-SIZE';
const x = r.x + r.width / 2, y = r.y + r.height / 2;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return 'OFF-SCREEN';
const top = document.elementFromPoint(x, y);
if (!top) return 'OFF-SCREEN';
// `top.contains(el)` is NOT a hit and must never be added back. An ANCESTOR
// receiving the click is precisely what occlusion looks like when the
// overlay is a parent or a parent's ::after, and an ancestor trivially
// contains its descendant — that clause made version one report OK for a
// real overlay. A DESCENDANT receiving it is fine: <a><img> resolves to the
// img and the anchor still gets the click.
return (el === top || el.contains(top)) ? 'OK' : 'OCCLUDED';
}"""
def probe(page, url: str) -> list[str]:
bad = []
page.goto(url, wait_until="networkidle")
# Hover every card first: these UIs reveal controls on hover, and an
# opacity-0 control still occupies layout and still occludes.
for card in page.locator("article.card").all():
try:
card.hover(timeout=1500)
except Exception:
pass
for el in page.locator("button, a.dl-link, a.thumb").all():
try:
# ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first,
# every control below the fold reports OCCLUDED and the probe
# drowns its real findings in noise — which is what the second
# version did on a page with sixteen kept booths.
el.scroll_into_view_if_needed(timeout=1500)
verdict = el.evaluate(HIT)
except Exception:
continue
if verdict in ("OCCLUDED", "ZERO-SIZE"):
label = (el.get_attribute("aria-label")
or el.get_attribute("title")
or (el.text_content() or "").strip()[:30] or "?")
bad.append(f"{url} {verdict:<10} {label}")
return bad
def main(argv: list[str]) -> int:
urls = argv[1:] or [DEFAULT]
failures = []
with sync_playwright() as pw:
b = pw.chromium.launch()
pg = b.new_page(viewport={"width": 1400, "height": 900})
for u in urls:
failures += probe(pg, u)
b.close()
if failures:
print("UNCLICKABLE CONTROLS:")
for f in failures:
print(" ", f)
return 1
print(f"all controls hittable across {len(urls)} page(s)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))