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.
110 lines
4.7 KiB
Python
Executable File
110 lines
4.7 KiB
Python
Executable File
#!/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))
|