Files
booth/scripts/layout-probe.py
T
Vuong Hoang aa61fcf5fd test(probe): teach the layout probe about <details>, and guard the flag parser
THE PROBE. A control inside a CLOSED <details> is laid out but sits
outside its collapsed parent's box, so elementFromPoint at its centre
returns an ancestor and it reports OCCLUDED — 23 of them on sindra-set,
every one a false positive. Verified both ways before believing it:
closed, elementFromPoint returns div.gallery; opened, the button itself,
and a real trial click lands on it.

Opening every <details> rather than skipping them is the deliberate
choice. Skipping would make the probe quiet by declaring put-away
controls out of scope, and the add-note button inside
details.item-addnote is exactly the class of control this instrument
exists to check. Fourth false-positive class this probe has grown a
guard for; the other three are already in its header.

THE FLAG PARSER. Three cases that silently break and are cheap to
pin: the flags on either side of the glob (a session should not have to
remember which), a why carrying quotes, an em-dash, a newline and
non-ASCII, and --why with no value after it, which must produce usage
rather than eating the booth name and creating a booth called nothing.

313 tests.
2026-09-22 01:01:20 -07:00

128 lines
5.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
# ⚠ OPEN EVERY <details> FIRST. A control inside a CLOSED one is laid out
# but sits outside its collapsed parent's box, so `elementFromPoint` at its
# centre returns an ancestor and it reports OCCLUDED — 23 of them on
# `sindra-set`, every one a false positive, because the only way an operator
# reaches that button is by opening the disclosure first. Verified both
# ways: closed -> elementFromPoint returns div.gallery; opened -> the button
# itself, and a real trial click lands on it.
#
# Opening rather than SKIPPING is deliberate. Skipping would make the probe
# quiet by declaring put-away controls out of scope, and the add-note button
# inside `details.item-addnote` is exactly the kind of control this
# instrument exists to check. Open it and ask the real question.
for d in page.locator("details:not([open])").all():
try:
d.evaluate("(el) => el.open = true")
except Exception:
pass
page.wait_for_timeout(150)
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))