Files
Project-SDE-WP-Suite/tests/form_structure_check.py
n.siegfried 0dcea8d725 T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:37:10 -07:00

474 lines
23 KiB
Python

#!/usr/bin/env python3
"""Does the creator's form have structure? — F6 / D3, T7.2.
`F6` measured the creator at 11 cards in one 5,399px scroll with a strip of jump
chips standing in for structure. `D3` settled the shape: not tabs — one page, a
rail down the side, sections collapsible, only the current one open, plus an
`Expand all` for people who would rather scroll straight through. It also amended
`F6`'s height criterion to **at rest**, in writing, because the answer chosen and
the criterion as written could not both hold.
1. F6 no longer reproduces: at rest the page is under two screen heights
2. the rail is built from the cards, so CR-006 suppression reaches it
3. every heading is a real disclosure button, every rail entry a real button
4. a section is addressable, survives refresh, and Back moves between sections
5. unsaved work survives moving between sections (T4.3)
6. Expand all does what it says, and is remembered
7. keyboard: reach the rail by Tab, activate by Enter and by Space
8. 390px and 1440px
Check 7 dispatches real key events through CDP. `page.key()` dispatches a
synthetic KeyboardEvent on `document`, which never reaches a listener bound to a
button and never triggers native activation — a rail with no keyboard support at
all would report a clean pass. That mistake was made once already, in wave 5.
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
from sections_check import set_sop # noqa: E402
def settle(seconds=1.0):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
# Reused, not rewritten. stepper_check.py already carries the recipe that works -
# nativeVirtualKeyCode alongside windowsVirtualKeyCode, rawKeyDown for keys with no
# text, unmodifiedText for the ones that have it - and the note explaining why the
# NEXT navigation after a real key press can stall the whole CDP session.
from stepper_check import press, dismiss_dialogs # noqa: E402
def open_creator(page, base, tok, query="?project=projA", width=1440):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(width, 900, mobile=(width <= 500))
# BL-020, and it bites the probe before it bites anybody else: once a real key
# press has given the page sticky user activation, WPAutosave's beforeunload
# guard can raise a browser-level "Leave site?" prompt on the next navigation.
# It is not JavaScript, so the alert() stub cannot see it, and an unanswered one
# stalls the CDP session rather than failing a check. Clear the form's dirty
# state first, then answer anything that still opens.
if page.eval("!!window.wpCreatorReady") is True:
# Only meaningful once a creator is actually loaded. Running it against
# about:blank on the first call cost this probe its very first check.
page.eval("typeof wpMarkFormClean === 'function' ? (wpMarkFormClean(), 1) : 0")
page.goto(base + "/wp-creation-index.html" + query)
dismiss_dialogs(page)
ok = wait_creator(page)
settle(1.6)
return ok
RAIL_JS = """(() => JSON.stringify(
[...document.querySelectorAll('.sec-rail-item')].map(b => ({
sec: b.dataset.sec,
label: (b.textContent || '').trim(),
tag: b.tagName,
current: b.getAttribute('aria-current') === 'true',
}))))()"""
CARDS_JS = """(() => JSON.stringify(
[...document.querySelectorAll('.main > .card')]
.filter(c => c.id !== 'saved-card' && !c.hidden && c.style.display !== 'none'
&& c.querySelector('.section-title, .sub-heading'))
.map(c => {
const btn = c.querySelector('.card-toggle');
const body = c.querySelector(':scope > .card-body');
return {
id: c.id,
toggleTag: btn ? btn.tagName : null,
expanded: btn ? btn.getAttribute('aria-expanded') : null,
controls: btn ? btn.getAttribute('aria-controls') : null,
controlsExists: btn && btn.getAttribute('aria-controls')
? !!document.getElementById(btn.getAttribute('aria-controls')) : false,
bodyHidden: body ? !!body.hidden : null,
collapsed: c.classList.contains('collapsed'),
};
})))()"""
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-formstruct-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
set_sop(db_path, {}) # a SOP the app can read (BL-018)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nThe creator's form structure - F6 / D3\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok, db_path)
finally:
page.close()
browser.close()
print("\n" + "-" * 54)
total = len(_PASS) + len(_FAIL)
print("%d/%d checks passed." % (len(_PASS), total))
for f in _FAIL:
print(" - " + f)
if _FAIL:
return 1
print("\nResult: " + _c("ALL PASS", "32") + " - the form has structure.")
return 0
finally:
if server is not None:
try:
server.terminate()
except Exception:
pass
def run(page, base, tok, db_path):
# ── 1. F6 ────────────────────────────────────────────────────────────────
print("\n1. F6: the 5,399px scroll")
chk("the creator boots", open_creator(page, base, tok))
h = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollHeight,
view: window.innerHeight,
cards: document.querySelectorAll('.main > .card').length,
chips: document.querySelectorAll('.sec-chip').length,
bar: document.querySelectorAll('.section-nav-bar').length,
})"""))
screens = h["scroll"] / float(h["view"] or 1)
print(" at rest: %spx over %spx viewport = %.2f screens, %d cards"
% (h["scroll"], h["view"], screens, h["cards"]))
# D3's amendment, quoted: "no single view exceeds roughly two screen heights at
# 1440px AT REST - that is, with the default collapse state, which is the state
# the page is actually in when it loads."
chk("at rest the page is under two screen heights (F6 / D3)", screens <= 2.0,
"%.2f screens (%spx / %spx)" % (screens, h["scroll"], h["view"]))
chk("the jump chips are gone", h["chips"] == 0, h["chips"])
chk("...and so is the strip that held them", h["bar"] == 0, h["bar"])
# Comments stripped first. BL-017 is the entry about a metric that counted its
# own explanation, and the comment above this rewrite contains the words
# "<span onclick>" precisely because it is about removing them.
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"html", "wp-creation-app.js"), encoding="utf-8").read()
code = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
code = re.sub(r"(?m)^\s*//.*$", "", code)
chk("the creator builds no section chip at all", "sec-chip" not in code,
[l for l in code.splitlines() if "sec-chip" in l][:2])
# The dashboard's status filter is the OTHER <span onclick>. It is not this
# task's - the C1 audit at T9.5 drives the app-wide count to 0 - but counting it
# here means T7.2 cannot be read as having cleared something it did not.
spans = len(re.findall(r"<span[^>]*onclick", code))
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
% spans)
# T9.5 converted the dashboard chip to a button, so the count is 0 now -
# pinned there, because a new span-with-onclick would be a C1 regression.
chk("...and none is left in this file at all (the chip became a button at T9.5)",
spans == 0, spans)
# ── 2. the rail ──────────────────────────────────────────────────────────
print("\n2. the rail is built from the cards")
rail = json.loads(page.eval(RAIL_JS))
cards = json.loads(page.eval(CARDS_JS))
chk("the rail has an entry per visible section", len(rail) == len(cards),
"%d entries vs %d sections" % (len(rail), len(cards)))
chk("...in the same order as the form",
[r["sec"] for r in rail] == [c["id"] for c in cards],
{"rail": [r["sec"] for r in rail], "form": [c["id"] for c in cards]})
chk("...every one of them a real button",
all(r["tag"] == "BUTTON" for r in rail), [r["tag"] for r in rail])
chk("...and every entry is named", all(r["label"] for r in rail), rail)
chk("exactly one is marked current",
sum(1 for r in rail if r["current"]) == 1,
[r["label"] for r in rail if r["current"]])
print("\n CR-006: a suppressed section leaves the rail with the form")
labels_before = [r["label"] for r in rail]
set_sop(db_path, {"assets": False})
open_creator(page, base, tok)
rail2 = json.loads(page.eval(RAIL_JS))
labels_after = [r["label"] for r in rail2]
chk("Assets is gone from the form", page.eval("""(() => {
const el = document.querySelector('#asset-card');
return !!el && el.hidden;
})()"""))
chk("...and gone from the rail", "Assets" not in labels_after,
labels_after)
chk("...and nothing else left with it",
[l for l in labels_before if l != "Assets"] == labels_after,
{"before": labels_before, "after": labels_after})
set_sop(db_path, {})
# ── 3. real controls ─────────────────────────────────────────────────────
print("\n3. every heading is a real disclosure button")
open_creator(page, base, tok)
cards = json.loads(page.eval(CARDS_JS))
chk("every section has a <button> heading",
all(c["toggleTag"] == "BUTTON" for c in cards),
[(c["id"], c["toggleTag"]) for c in cards if c["toggleTag"] != "BUTTON"])
chk("...each carrying aria-expanded",
all(c["expanded"] in ("true", "false") for c in cards),
[(c["id"], c["expanded"]) for c in cards if c["expanded"] not in ("true", "false")])
chk("...and aria-controls pointing at an element that exists",
all(c["controlsExists"] for c in cards),
[(c["id"], c["controls"]) for c in cards if not c["controlsExists"]])
chk("aria-expanded agrees with what is actually hidden",
all((c["expanded"] == "true") == (not c["bodyHidden"]) for c in cards),
[(c["id"], c["expanded"], c["bodyHidden"]) for c in cards
if (c["expanded"] == "true") == c["bodyHidden"]])
open_at_rest = [c["id"] for c in cards if c["expanded"] == "true"]
chk("exactly one section is open at rest", len(open_at_rest) == 1, open_at_rest)
chk("...and it is the first one", open_at_rest[:1] == [cards[0]["id"]],
{"open": open_at_rest, "first": cards[0]["id"]})
# ── 4. addressable ───────────────────────────────────────────────────────
print("\n4. a section is addressable")
target = cards[3]["id"] if len(cards) > 3 else cards[-1]["id"]
page.eval("""(() => {
const b = [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s);
b.click();
})()""" % json.dumps(target))
settle(1.0)
chk("clicking a rail entry records it in the URL",
("section=" + target) in page.eval("location.search"),
page.eval("location.search"))
chk("...opens that section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
chk("...closes the one that was open", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(cards[0]["id"])) == "false")
chk("...moves focus into it, rather than only scrolling", page.eval("""(() => {
const c = document.getElementById(%s);
return !!c && c.contains(document.activeElement);
})()""" % json.dumps(target)),
page.eval("document.activeElement ? document.activeElement.className : '(none)'"))
chk("...and marks it current in the rail", page.eval("""(() => {
const cur = document.querySelector('.sec-rail-item[aria-current="true"]');
return !!cur && cur.dataset.sec === %s;
})()""" % json.dumps(target)))
print("\n ...and survives a refresh")
page.goto(base + "/wp-creation-index.html?project=projA&section=" + target)
dismiss_dialogs(page)
chk("the creator boots on a section deep link", wait_creator(page))
settle(1.4)
chk("the deep-linked section is the open one", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("""(() => JSON.stringify([...document.querySelectorAll('.card-toggle')]
.filter(b => b.getAttribute('aria-expanded') === 'true')
.re""" + """duce((a, b) => a.concat(b.closest('.card').id), [])))()"""))
chk("...and every other section is collapsed", page.eval("""(() => {
return [...document.querySelectorAll('.main > .card .card-toggle')]
.filter(b => b.getAttribute('aria-expanded') === 'true').length === 1;
})()"""))
print("\n ...and Back moves between sections")
other = cards[1]["id"]
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(other))
settle(1.0)
page.eval("history.back()")
settle(1.4)
chk("Back returns to the section you came from", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("location.search"))
# ── 5. T4.3 ──────────────────────────────────────────────────────────────
print("\n5. unsaved work survives moving between sections (T4.3)")
open_creator(page, base, tok)
typed = "structure probe subject"
page.eval("""(() => {
const el = document.getElementById('wp_subject');
el.value = %s;
el.dispatchEvent(new Event('input', {bubbles:true}));
})()""" % json.dumps(typed))
settle(0.6)
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(target))
settle(1.0)
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(cards[0]["id"]))
settle(1.0)
chk("a value typed before moving section is still there after coming back",
page.eval("(document.getElementById('wp_subject')||{}).value||''") == typed,
page.eval("(document.getElementById('wp_subject')||{}).value||''"))
chk("...because nothing was unmounted; the field is the same element",
page.eval("!!document.getElementById('wp_subject')"))
# ── 6. Expand all ────────────────────────────────────────────────────────
print("\n6. Expand all")
open_creator(page, base, tok)
total_sections = len(json.loads(page.eval(CARDS_JS)))
page.eval("document.getElementById('sec-expand-all').click()")
settle(1.0)
# Count section toggles only: saved-card has a toggle too but is not a section
# (it is excluded from CARDS_JS and the rail), so it is asserted separately below.
expanded_sections_js = """(() =>
[...document.querySelectorAll('.main > .card .card-toggle')]
.filter(b => b.closest('.card').id !== 'saved-card'
&& b.getAttribute('aria-expanded') === 'true').length)()"""
chk("it expands every section",
page.eval(expanded_sections_js) == total_sections,
page.eval(expanded_sections_js))
chk("...and the saved-package list opens with it (deliberate: it collapses at "
"rest like everything else, so Expand all must reach it)",
page.eval("""(() => {
const b = document.querySelector('#saved-card .card-toggle');
return b && b.getAttribute('aria-expanded') === 'true';
})()""") is True)
chk("...and says so with aria-pressed",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "true")
chk("...and its label becomes the inverse action",
"Collapse" in (page.eval(
"(document.getElementById('sec-expand-all')||{}).textContent||''") or ""),
page.eval("(document.getElementById('sec-expand-all')||{}).textContent||''"))
tall = page.eval("document.documentElement.scrollHeight")
print(" expanded: %spx - deliberately over the at-rest bar" % tall)
chk("expanded is taller than at rest, which is the point of the control",
tall > 2 * page.eval("window.innerHeight"), tall)
print("\n ...and it is remembered")
open_creator(page, base, tok)
chk("a new load keeps Expand all on",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "true")
page.eval("document.getElementById('sec-expand-all').click()")
settle(0.8)
open_creator(page, base, tok)
chk("...and turning it off is remembered too",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "false")
# ── 7. keyboard ──────────────────────────────────────────────────────────
print("\n7. keyboard")
open_creator(page, base, tok)
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so these readings mean something",
page.eval("document.hasFocus()") is True)
chk("every rail entry is in the tab order", page.eval("""(() => {
return [...document.querySelectorAll('.sec-rail-item')]
.every(b => b.tabIndex >= 0);
})()"""))
chk("...and so is every section heading", page.eval("""(() => {
return [...document.querySelectorAll('.card-toggle')].every(b => b.tabIndex >= 0);
})()"""))
# Enter on a focused rail entry.
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).focus(); })()""" % json.dumps(target))
press(page, "Enter")
settle(1.0)
chk("Enter on a focused rail entry opens that section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("location.search"))
# Space on a focused section heading collapses it.
page.eval("""(() => {
document.getElementById(%s).querySelector('.card-toggle').focus();
})()""" % json.dumps(target))
press(page, "Space")
settle(0.8)
chk("Space on a focused heading collapses its section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "false")
press(page, "Space")
settle(0.8)
chk("...and expands it again", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
chk("every new control draws a focus ring of at least 3:1", page.eval("""(() => {
const sel = ['.sec-rail-item', '.sec-rail-all', '.card-toggle'];
for (const s of sel) {
const el = document.querySelector(s);
if (!el) return false;
el.focus();
const cs = getComputedStyle(el);
if (cs.outlineStyle === 'none' && cs.boxShadow === 'none') return false;
}
return true;
})()"""))
# ── 8. both widths ───────────────────────────────────────────────────────
print("\n8. both widths")
for w in (390, 1440):
open_creator(page, base, tok, width=w)
vis = page.eval("""(() => {
const r = document.getElementById('section-rail');
if (!r || r.hidden) return 'hidden';
const b = r.getBoundingClientRect();
return b.width > 0 && b.height > 0 ? 'shown' : 'zero';
})()""")
chk("%dpx: the rail is rendered" % w, vis == "shown", vis)
chk("%dpx: every rail entry is a 44px tap target" % w, page.eval("""(() => {
return [...document.querySelectorAll('.sec-rail-item')]
.every(b => { const r = b.getBoundingClientRect();
return r.height >= 30 && r.width >= 44; });
})()"""), page.eval("""(() => JSON.stringify(
[...document.querySelectorAll('.sec-rail-item')].slice(0, 3)
.map(b => { const r = b.getBoundingClientRect();
return [Math.round(r.width), Math.round(r.height)]; })))()"""))
side = "beside" if w == 1440 else "above"
chk("%dpx: the rail sits %s the form" % (w, side), page.eval("""(() => {
const r = document.getElementById('section-rail').getBoundingClientRect();
const m = document.querySelector('.main').getBoundingClientRect();
return %s;
})()""" % ("r.left >= m.right - 4" if w == 1440 else "r.top <= m.top + 4")),
page.eval("""(() => {
const r = document.getElementById('section-rail').getBoundingClientRect();
const m = document.querySelector('.main').getBoundingClientRect();
return JSON.stringify({rail: [Math.round(r.left), Math.round(r.top)],
main: [Math.round(m.left), Math.round(m.top),
Math.round(m.right)]});
})()"""))
# BL-001, still pinned. Its cause is the help.js tooltip, not this layout - see
# the entry. Recorded here so T7.2 is on record as having measured it.
open_creator(page, base, tok, width=390)
over = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
})"""))
print(" BL-001 at 390px: scrollWidth %s vs clientWidth %s (help.js tooltip; T9.5)"
% (over["scroll"], over["client"]))
if __name__ == "__main__":
sys.exit(main())