`page.locator("details:not([open])").all()` hands back POSITIONAL locators
that re-resolve against the current DOM, and `:not([open])` stops matching
an element the moment it is opened — so opening them one at a time shrinks
the set underneath the indices and leaves some closed. Those then report
OCCLUDED, which is exactly the false-positive class the block was added to
remove. One on booth-redesign, three on cr123a-to-d-sleeve, one on
denoise-first-run, and invisible as a bug because a false positive is
shaped like a finding.
Measured both hypotheses rather than guessing between them: per-element
loop against a single document-wide evaluate, at 150 ms and 1000 ms settle.
The loop reports them at either wait; the single pass reports none at
either. The variable was the method, not the timing.
One evaluate over the whole document now. All three pages clean.
Also carries the ROADMAP U5 row, the two-panel record in
persistent-memory.d/, and the memory index line for it.
136 lines
6.4 KiB
Python
Executable File
136 lines
6.4 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
|
||
# ⚠ 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.
|
||
#
|
||
# ⚠ ONE evaluate over the whole document, NOT a locator loop. `.all()` hands
|
||
# back positional locators that re-resolve against the CURRENT DOM, and
|
||
# `details:not([open])` stops matching an element the moment it is opened —
|
||
# so opening them one at a time shrinks the set underneath the indices and
|
||
# some are never opened at all. That left exactly the closed-<details>
|
||
# false positives this block exists to remove: 1 on booth-redesign, 3 on
|
||
# cr123a-to-d-sleeve, stable across five runs and invisible as a bug
|
||
# because a false positive looks like a finding. Measured both ways at
|
||
# 150 ms and 1000 ms settle: the loop reports them at either wait, the
|
||
# single pass reports none at either. The variable was the method, not the
|
||
# timing.
|
||
page.evaluate("document.querySelectorAll('details').forEach(d => d.open = true)")
|
||
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))
|