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>
548 lines
27 KiB
Python
548 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""The SOP wizard's step rail — A4 / S9 / C1 (T5.1).
|
|
|
|
The rail was ten div elements carrying onclick inside a horizontal scroller: not
|
|
in the tab order, unreachable by keyboard, and silent about progress. The one
|
|
thing on the page that did say where you were was a "1 / 10" pill in the app bar,
|
|
detached from the control it described.
|
|
|
|
Every one of T5.1's done-whens is checked here, because nothing that already
|
|
existed could check any of them:
|
|
|
|
1. every step is a <button> element (ten until T5.4 appended Locations)
|
|
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
|
|
3. the current step is exposed with aria-current
|
|
4. complete / current / unavailable are distinguishable WITHOUT colour
|
|
5. the "1 / 10" counter is gone
|
|
6. the app-wide div-with-onclick count dropped by 10 against the wave 0 baseline
|
|
|
|
and two things that are not on the list but would make the rail a liar:
|
|
|
|
7. the rail and validateStep() agree about which steps are open
|
|
8. no path through the rail reaches a native dialog
|
|
|
|
Note (7): the reachability rule is deliberately the guard's own rule — you may
|
|
leave the step you are on once its required fields are filled — and NOT "lock
|
|
everything after the first unmet gate anywhere". The guard has never enforced the
|
|
second, so a rail that drew it would show a padlock the Next button walks past.
|
|
|
|
Trap: window.alert and window.confirm are stubbed before anything is driven. A
|
|
native dialog does not fail a headless CDP session, it HANGS it, which reads as
|
|
"the browser would not start" three minutes later.
|
|
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
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
|
|
|
|
HTML_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "html")
|
|
|
|
# Wave 0 recorded 12. T5.1 must take ten of them.
|
|
BASELINE_DIV_ONCLICK = 12
|
|
|
|
# Ten at T5.1; +1 at T5.4 (CR-005 Locations); +1 at T5.5 (CR-006 Sections). Named
|
|
# rather than repeated, so the next step to be added moves one number instead of
|
|
# eight assertions.
|
|
STEP_COUNT = 12
|
|
|
|
# Swallow the wizard's remaining native dialogs and record that they fired. The
|
|
# wizard still has them until T5.8; this proves the RAIL never reaches one.
|
|
STUB = """
|
|
window.__dialogs = [];
|
|
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
|
|
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
|
|
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
|
|
true
|
|
"""
|
|
|
|
# One row per step, as the browser sees it.
|
|
RAIL_JS = r"""
|
|
JSON.stringify([...document.querySelectorAll('#step-rail-list .step-btn')].map(b => {
|
|
const cs = getComputedStyle(b);
|
|
const mk = b.querySelector('.step-btn-marker');
|
|
const mcs = mk ? getComputedStyle(mk) : null;
|
|
return {
|
|
step: +b.dataset.step,
|
|
tag: b.tagName,
|
|
type: b.getAttribute('type'),
|
|
tabindex: b.tabIndex,
|
|
disabled: !!b.disabled,
|
|
ariaDisabled: b.getAttribute('aria-disabled'),
|
|
ariaCurrent: b.getAttribute('aria-current'),
|
|
label: (b.querySelector('.step-btn-label') || {}).textContent || '',
|
|
state: ((b.querySelector('.step-btn-state') || {}).textContent || '').trim(),
|
|
marker: mk ? (mk.textContent || '').trim() : '',
|
|
markerBorderStyle: mcs ? mcs.borderTopStyle : '',
|
|
markerBg: mcs ? mcs.backgroundColor : '',
|
|
color: cs.color,
|
|
bg: cs.backgroundColor,
|
|
visible: !!(b.getBoundingClientRect().width && b.getBoundingClientRect().height),
|
|
};
|
|
}))
|
|
"""
|
|
|
|
|
|
# Real key events, not a synthetic KeyboardEvent. Page.key() dispatches on
|
|
# `document`, which never reaches a listener bound to the rail and never triggers
|
|
# a button's native Enter/Space activation — it would report a clean pass on a
|
|
# rail with no keyboard support at all.
|
|
_KEYS = {
|
|
"ArrowDown": dict(key="ArrowDown", code="ArrowDown", vk=40),
|
|
"ArrowUp": dict(key="ArrowUp", code="ArrowUp", vk=38),
|
|
"ArrowLeft": dict(key="ArrowLeft", code="ArrowLeft", vk=37),
|
|
"ArrowRight": dict(key="ArrowRight", code="ArrowRight", vk=39),
|
|
"Home": dict(key="Home", code="Home", vk=36),
|
|
"End": dict(key="End", code="End", vk=35),
|
|
"Enter": dict(key="Enter", code="Enter", vk=13, text="\r"),
|
|
"Space": dict(key=" ", code="Space", vk=32, text=" "),
|
|
}
|
|
|
|
|
|
def press(page, name, settle_s=0.35):
|
|
spec = _KEYS[name]
|
|
common = {"key": spec["key"], "code": spec["code"],
|
|
"windowsVirtualKeyCode": spec["vk"], "nativeVirtualKeyCode": spec["vk"]}
|
|
down = dict(common, type="keyDown" if "text" in spec else "rawKeyDown")
|
|
if "text" in spec:
|
|
down["text"] = spec["text"]
|
|
down["unmodifiedText"] = spec["text"]
|
|
page.ws.call("Input.dispatchKeyEvent", down)
|
|
page.ws.call("Input.dispatchKeyEvent", dict(common, type="keyUp"))
|
|
time.sleep(settle_s)
|
|
page.ws.drain(0.2)
|
|
|
|
|
|
def dismiss_dialogs(page):
|
|
"""Answer any native dialog that opened during a navigation.
|
|
|
|
A real key event gives the page sticky user activation, which is what lets
|
|
WPAutosave's beforeunload guard raise a browser-level "Leave site?" prompt on
|
|
the NEXT navigation. That prompt is not JavaScript, so the alert() stub cannot
|
|
catch it, and an unanswered one stalls the CDP session rather than failing it.
|
|
"""
|
|
if not any(e.get("method") == "Page.javascriptDialogOpening" for e in page.ws.events):
|
|
return
|
|
for e in list(page.ws.events):
|
|
if e.get("method") == "Page.javascriptDialogOpening":
|
|
try:
|
|
page.ws.call("Page.handleJavaScriptDialog", {"accept": True})
|
|
except Exception:
|
|
pass
|
|
page.ws.drain(0.3)
|
|
|
|
|
|
def rail(page):
|
|
return json.loads(page.eval(RAIL_JS))
|
|
|
|
|
|
def settle(page, seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def fill_step1(page):
|
|
page.eval("""(() => {
|
|
for (const [id, v] of [['proj_name','Probe project'], ['proj_number','P-1'],
|
|
['proj_client','Probe client'], ['proj_division','Probe division'],
|
|
['proj_site','Probe site']]) {
|
|
const el = document.getElementById(id);
|
|
el.value = v;
|
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
}
|
|
return true;
|
|
})()""")
|
|
|
|
|
|
def clear_step1(page):
|
|
page.eval("""(() => {
|
|
const el = document.getElementById('proj_site');
|
|
el.value = '';
|
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
return true;
|
|
})()""")
|
|
|
|
|
|
def source_counts():
|
|
"""The wave 0 baseline metrics, measured the way file-map.md defines them."""
|
|
div_onclick = span_onclick = 0
|
|
for name in sorted(os.listdir(HTML_DIR)):
|
|
if not (name.endswith(".html") or name.endswith(".js")):
|
|
continue
|
|
with open(os.path.join(HTML_DIR, name), encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
div_onclick += len(re.findall(r"<div[^>]*onclick", src))
|
|
span_onclick += len(re.findall(r"<span[^>]*onclick", src))
|
|
return div_onclick, span_onclick
|
|
|
|
|
|
def run(page, base, tok):
|
|
def open_wizard(query="?project=projA"):
|
|
# Leave the outgoing page clean first: the unsaved-work guard from T4.3 is
|
|
# doing its job, and a "Leave site?" prompt would stall the navigation.
|
|
try:
|
|
page.eval("typeof sopMarkSaved === 'function' && sopMarkSaved()")
|
|
except Exception:
|
|
pass
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/work-package-suite.html" + query)
|
|
dismiss_dialogs(page)
|
|
settle(page, 1.8)
|
|
page.eval(STUB)
|
|
|
|
# ── 1. every step is a real button ────────────────────────────────────────
|
|
print("\n1. every step is a <button> element")
|
|
open_wizard()
|
|
rows = rail(page)
|
|
chk("the rail renders %d steps" % STEP_COUNT, len(rows) == STEP_COUNT,
|
|
"found %d" % len(rows))
|
|
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
|
|
[r["tag"] for r in rows])
|
|
chk('...with type="button", so none of them submits anything',
|
|
rows and all(r["type"] == "button" for r in rows), [r["type"] for r in rows])
|
|
chk("...and none is a div or a span with a click handler",
|
|
page.eval("!document.querySelector('#step-rail [onclick]')"))
|
|
chk("the rail is a landmark with a name",
|
|
page.eval("(document.getElementById('step-rail')||{}).tagName") == "NAV"
|
|
and bool(page.eval("(document.getElementById('step-rail')||{}).getAttribute('aria-label')")))
|
|
chk("...and an ordered list, so position carries order for free",
|
|
page.eval("!!document.querySelector('#step-rail ol#step-rail-list')"))
|
|
|
|
# ── 5. the counter is gone ────────────────────────────────────────────────
|
|
print("\n5. the orphaned 1 / 10 counter is gone")
|
|
chk("no #current-step element", page.eval("!document.getElementById('current-step')"))
|
|
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
|
|
chk("no .step-counter anywhere on the page",
|
|
page.eval("!document.querySelector('.step-counter')"))
|
|
chk("...and no bare 'N / %d' left in the app bar" % STEP_COUNT,
|
|
not re.search(r"\b\d+\s*/\s*%d\b" % STEP_COUNT,
|
|
page.eval("(document.querySelector('.header')||{}).textContent||''")),
|
|
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
|
|
|
|
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
|
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
|
divs, spans = source_counts()
|
|
# Re-pointed at T9.5: the C1 audit drove the app-wide count to ZERO (the
|
|
# last two - the wizard's constraint-library entries and the dashboard
|
|
# chips - became buttons). "Exactly baseline-10" was right while wave 9 was
|
|
# future; asserting <= that now would let regressions hide under the slack,
|
|
# so the pin is the final number.
|
|
chk("app-wide div-with-onclick is %d - the C1 target, reached at T9.5"
|
|
% divs, divs == 0, "counted %d" % divs)
|
|
chk("...and none of the survivors is in the wizard's rail",
|
|
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
|
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
|
|
|
# ── 3 + 4. states, told apart without colour ──────────────────────────────
|
|
print("\n3 + 4. current, complete and unavailable, without relying on colour")
|
|
rows = rail(page)
|
|
cur = [r for r in rows if r["ariaCurrent"] == "step"]
|
|
chk("exactly one step carries aria-current=step", len(cur) == 1,
|
|
[r["step"] for r in cur])
|
|
chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur)
|
|
chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur)
|
|
|
|
# projA's fixture project has no division or site, so step 1 is incomplete on
|
|
# a fresh load and everything ahead of it is genuinely out of reach.
|
|
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
|
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
|
[r["step"] for r in locked] == list(range(2, STEP_COUNT + 1)),
|
|
[r["step"] for r in locked])
|
|
chk("...each saying 'Locked' in words, not only in colour",
|
|
locked and all(r["state"] == "Locked" for r in locked),
|
|
list({r["state"] for r in locked}))
|
|
chk("...and drawn with a different marker SHAPE (dashed, not solid)",
|
|
locked and all(r["markerBorderStyle"] == "dashed" for r in locked),
|
|
list({r["markerBorderStyle"] for r in locked}))
|
|
chk("...while remaining focusable, so a keyboard user can be told why",
|
|
locked and all(r["tabindex"] >= 0 and not r["disabled"] for r in locked))
|
|
chk("...and carrying the reason on hover and on focus",
|
|
page.eval("!!document.querySelector('#step-rail-list .step-btn.is-locked[title]')"))
|
|
|
|
fill_step1(page)
|
|
page.eval("goToStep(3)")
|
|
settle(page, 0.8)
|
|
rows = rail(page)
|
|
by = {r["step"]: r for r in rows}
|
|
chk("once step 1 is filled and left behind, it reads Complete",
|
|
by[1]["state"] == "Complete", by[1])
|
|
chk("...with a tick rather than its number — a glyph, not a hue",
|
|
by[1]["marker"] == "✓", repr(by[1]["marker"]))
|
|
chk("...and step 3 is now the current one", by[3]["ariaCurrent"] == "step", by[3])
|
|
chk("nothing is locked while the current step has no required fields",
|
|
not [r for r in rows if r["ariaDisabled"] == "true"],
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
chk("a step never opened is neither complete nor locked",
|
|
by[9]["state"] == "" and by[9]["ariaDisabled"] is None, by[9])
|
|
# The three states must be separable with colour discarded entirely.
|
|
words = {by[1]["state"], by[3]["state"], "Locked"}
|
|
chk("complete / current / unavailable are three distinct words",
|
|
len(words) == 3, sorted(words))
|
|
|
|
# ── 2. keyboard ───────────────────────────────────────────────────────────
|
|
print("\n2. the rail is operable from the keyboard alone")
|
|
chk("every step is in the tab order",
|
|
all(r["tabindex"] >= 0 for r in rows), [r["tabindex"] for r in rows])
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"1\"]').focus()")
|
|
chk("focus lands on a step button",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1")
|
|
scroll_before = page.eval("window.scrollY")
|
|
press(page, "ArrowDown")
|
|
chk("ArrowDown moves to the next step",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "2",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "ArrowUp")
|
|
chk("ArrowUp moves back",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "ArrowRight")
|
|
chk("ArrowRight works too, for a rail that is a row at some widths",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "2",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "End")
|
|
chk("End jumps to the last step",
|
|
page.eval("(document.activeElement.dataset||{}).step") == str(STEP_COUNT),
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "Home")
|
|
chk("Home jumps to the first",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
chk("...and the arrow keys do not scroll the page out from under it",
|
|
page.eval("window.scrollY") == scroll_before,
|
|
[scroll_before, page.eval("window.scrollY")])
|
|
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"5\"]').focus()")
|
|
press(page, "Enter")
|
|
settle(page, 0.7)
|
|
chk("Enter activates the focused step", page.eval("currentStep") == 5,
|
|
page.eval("currentStep"))
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"2\"]').focus()")
|
|
press(page, "Space")
|
|
settle(page, 0.7)
|
|
chk("Space activates it too", page.eval("currentStep") == 2, page.eval("currentStep"))
|
|
chk("...and the step content actually changed, not just the variable",
|
|
page.eval("getComputedStyle(document.getElementById('sop-step-2')).display") == "block")
|
|
|
|
# ── 7. the rail and the guard agree ───────────────────────────────────────
|
|
print("\n7. the rail and validateStep() agree about what is open")
|
|
page.eval("goToStep(1)")
|
|
settle(page, 0.6)
|
|
clear_step1(page)
|
|
# No renderStepRail() here on purpose: the rail must track the form as it is
|
|
# typed, not only when you navigate. A rail that says "locked" over a form you
|
|
# have just filled in is worse than the strip it replaced.
|
|
rows = rail(page)
|
|
chk("emptying a required field re-locks the steps ahead, with no navigation",
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, STEP_COUNT + 1)),
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
chk("...and the guard refuses the same move",
|
|
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
|
# Until T5.8 this call raised the wizard's surviving alert, and this check
|
|
# recorded that rather than hiding it. T5.8 replaced it with an error at the
|
|
# field, so the same call now refuses silently and marks the field instead.
|
|
guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
|
chk("...with no dialog at all, since T5.8", not guard_dialogs, guard_dialogs)
|
|
chk("...and an error at the field that is in the way",
|
|
bool((page.eval("(document.getElementById('proj_site_err')||{}).textContent||''")).strip()),
|
|
page.eval("(document.getElementById('proj_site_err')||{}).textContent||''"))
|
|
chk("...while going BACKWARDS is still allowed, so nobody is trapped",
|
|
page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2,
|
|
page.eval("currentStep"))
|
|
|
|
page.eval("goToStep(1)")
|
|
settle(page, 0.6)
|
|
fill_step1(page)
|
|
rows = rail(page)
|
|
chk("filling it unlocks them again, as it is typed",
|
|
not [r for r in rows if r["ariaDisabled"] == "true"],
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
|
|
# ── the blocked click is useful, not just refused ─────────────────────────
|
|
print("\n a refused step says why and puts the cursor where the work is")
|
|
clear_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"7\"]').click()")
|
|
settle(page, 0.6)
|
|
msg = page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''")
|
|
chk("the refusal is announced, not silent", bool(msg.strip()), repr(msg))
|
|
chk("...and names both the step refused and the step in the way",
|
|
"Platforms" in msg and "Project" in msg, repr(msg))
|
|
chk("...through a live region, so a screen reader hears it",
|
|
page.eval("(document.getElementById('step-rail-msg')||{}).getAttribute('role')") == "alert")
|
|
chk("...and focus moved to the empty required field",
|
|
page.eval("(document.activeElement||{}).id") == "proj_site",
|
|
page.eval("(document.activeElement||{}).id"))
|
|
chk("...without changing which step is open", page.eval("currentStep") == 1,
|
|
page.eval("currentStep"))
|
|
fill_step1(page)
|
|
chk("...and the refusal clears itself once the field is filled",
|
|
not (page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''")
|
|
.strip()),
|
|
page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''"))
|
|
|
|
# ── 8. no native dialogs on any rail path ─────────────────────────────────
|
|
print("\n8. no rail interaction reaches a native dialog")
|
|
fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
|
chk("nothing opened alert / confirm / prompt during the whole rail exercise",
|
|
not fired, fired[:3])
|
|
|
|
# ── the URL still tracks the step (S3 / T4.2 must not regress) ────────────
|
|
print("\n the rail still records where you are in the URL (S3)")
|
|
fill_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"2\"]').click()")
|
|
settle(page, 0.8)
|
|
hlen = page.eval("history.length")
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"6\"]').click()")
|
|
settle(page, 0.8)
|
|
chk("clicking a step puts it in the address bar",
|
|
"step=6" in page.eval("location.search"), page.eval("location.search"))
|
|
chk("...as a history entry", page.eval("history.length") > hlen)
|
|
page.eval("history.back()")
|
|
settle(page, 1.1)
|
|
chk("...and Back returns to the previous step", page.eval("currentStep") == 2,
|
|
page.eval("currentStep"))
|
|
# Back to a URL with NO step at all does not return to step 1 — the popstate
|
|
# handler parses `step` and ignores a NaN. That is T4.2's restore rather than
|
|
# the rail's, it predates this task, and it is logged as BL-016.
|
|
chk("...and the known step-1 gap is still exactly that, and no wider",
|
|
page.eval("(() => { history.back(); return true; })()") is True)
|
|
settle(page, 1.1)
|
|
chk("...(BL-016) Back to a step-less URL leaves the step where it was",
|
|
page.eval("currentStep") == 2 and "step=" not in page.eval("location.search"),
|
|
[page.eval("currentStep"), page.eval("location.search")])
|
|
|
|
# ── narrow width: the gloved-hands surface ────────────────────────────────
|
|
print("\n at 390px the rail collapses to a disclosure and still works")
|
|
page.viewport(390, 780, mobile=True)
|
|
open_wizard()
|
|
page.viewport(390, 780, mobile=True)
|
|
settle(page, 1.0)
|
|
chk("the disclosure button is the visible control",
|
|
page.eval("getComputedStyle(document.getElementById('step-rail-toggle')).display") != "none")
|
|
chk("...starting collapsed", page.eval(
|
|
"getComputedStyle(document.getElementById('step-rail-list')).display") == "none")
|
|
chk("...and saying so", page.eval(
|
|
"document.getElementById('step-rail-toggle').getAttribute('aria-expanded')") == "false")
|
|
chk("...naming the step you are on, which is what the counter used to do",
|
|
"Project" in (page.eval(
|
|
"(document.getElementById('step-rail-toggle')||{}).textContent||''")),
|
|
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
|
|
page.click("#step-rail-toggle")
|
|
settle(page, 0.5)
|
|
chk("tapping it reveals every step",
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".filter(b => b.getBoundingClientRect().height > 0).length") == STEP_COUNT,
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".filter(b => b.getBoundingClientRect().height > 0).length"))
|
|
chk("...and flips aria-expanded", page.eval(
|
|
"document.getElementById('step-rail-toggle').getAttribute('aria-expanded')") == "true")
|
|
chk("every step is at least a 44px tap target",
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".every(b => b.getBoundingClientRect().height >= 44)"),
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".map(b => Math.round(b.getBoundingClientRect().height))"))
|
|
chk("the page does not scroll sideways at 390px",
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
fill_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"4\"]').click()")
|
|
settle(page, 0.8)
|
|
chk("choosing a step navigates", page.eval("currentStep") == 4, page.eval("currentStep"))
|
|
chk("...and closes the disclosure behind you", page.eval(
|
|
"document.getElementById('step-rail').classList.contains('is-collapsed')"))
|
|
|
|
print("\n and the wide layout puts it beside the form, not above it")
|
|
page.viewport(1440, 900)
|
|
open_wizard()
|
|
page.viewport(1440, 900)
|
|
settle(page, 1.2)
|
|
geo = json.loads(page.eval("""JSON.stringify((() => {
|
|
const r = document.getElementById('step-rail').getBoundingClientRect();
|
|
const c = document.querySelector('.step-content').getBoundingClientRect();
|
|
return {railRight: Math.round(r.right), contentLeft: Math.round(c.left),
|
|
railTop: Math.round(r.top), contentTop: Math.round(c.top),
|
|
sticky: getComputedStyle(document.getElementById('step-rail')).position};
|
|
})())"""))
|
|
chk("the rail sits to the LEFT of the form at desk width",
|
|
geo["railRight"] <= geo["contentLeft"] + 1, geo)
|
|
chk("...on the same row, not stacked above it",
|
|
abs(geo["railTop"] - geo["contentTop"]) < 40, geo)
|
|
chk("...and stays put while a three-screen step scrolls",
|
|
geo["sticky"] == "sticky", geo)
|
|
chk("the disclosure is out of the way at this width",
|
|
page.eval("getComputedStyle(document.getElementById('step-rail-toggle')).display")
|
|
== "none")
|
|
chk("the page does not scroll sideways at 1440px",
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
chk("the wizard still boots without a JavaScript error", not page.js_errors(),
|
|
page.js_errors())
|
|
|
|
|
|
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-stepper-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(db_path)
|
|
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("\nSOP step rail — A4 / S9 / C1\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
run(page, base, tok)
|
|
finally:
|
|
page.close()
|
|
browser.close()
|
|
finally:
|
|
if server:
|
|
server.kill()
|
|
try:
|
|
server.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception:
|
|
pass
|
|
import shutil
|
|
for _ in range(10):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
if not os.path.exists(tmpdir):
|
|
break
|
|
time.sleep(0.3)
|
|
|
|
total = len(_PASS) + len(_FAIL)
|
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
|
if _FAIL:
|
|
for f in _FAIL:
|
|
print(" - " + f)
|
|
return 1
|
|
print("\nResult: " + _c("ALL PASS — ten real buttons, keyboard operable, states in words.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|