diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index a476db2..0747381 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -220,6 +220,18 @@ python tests/autosave_check.py # S2/B5 — does unsaved work survive?
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
```
+Wave 5 added one more, for the same reason:
+
+```bash
+python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
+```
+
+It drives the rail with **real** key events over `Input.dispatchKeyEvent` rather than
+`page.key()`, which dispatches a synthetic `KeyboardEvent` on `document`. That event
+never reaches a listener bound to the rail and never triggers a button's native
+Enter/Space activation, so a rail with no keyboard support at all would have reported a
+clean pass — the same class of false green as `a11y_check.py`'s focus emulation.
+
Each tests what was *broken* rather than what is easy to assert. `aggregates_check.py` poisons
localStorage and demands the dashboard still report the server's total; a test that only
checked the totals were correct would have passed before `B4` was built. `a11y_check.py` runs
@@ -239,9 +251,23 @@ one.
| 8 | `pushState` | 0 | **2** (6 call sites via `wp-url.js`) | `T4.2` |
| — | `outline: none` in stylesheets | 6 | **1**, with its replacement one rule above | `T3.4`, `T4.7` |
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
+| 3 | `
`, `.help-tip` badges) are wave 9's to move
-and are unchanged.
+Metrics 2, 4 and 6 (creator dialogs, ``, `.help-tip` badges) are wave 9's to
+move and are unchanged.
+
+**Metric 3 after `T5.1`:** the two survivors are `wp-creation-index.html:383`
+(`.cmt-overlay`) and `work-package-suite-app.js:1046` (the constraint-library row). Both
+are wave 7 / wave 9 work. Nothing in the SOP wizard's markup carries a click handler on a
+non-interactive element any more.
+
+**Metric 1 is noisier than it looks** — the command matches `alert(` inside a *comment* as
+readily as inside code, and waves 3 and 4 left several comments explaining dialogs they
+were removing. That is why the figure was 80 before `T5.1` rather than the 79 wave 0
+recorded. `T5.1` removed two real calls (`validateStep`'s three conditions collapsed to
+one) and reworded its own comments so they do not inflate it. Logged as **BL-017**;
+`T5.8` records a comment-stripped figure alongside the raw one.
`server/smoketest.py` is the one that is **not** self-contained: it drives a server you point
it at and aborts unless `WP_SMOKE_USER` and `WP_SMOKE_PASSWORD` are set, because every route
diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md
index 3568808..d1520e6 100644
--- a/docs/waves/backlog.md
+++ b/docs/waves/backlog.md
@@ -264,6 +264,40 @@ deliberately deferred.
- **Why not now:** out of `A5`'s stated scope, and `A4`/`S9` rebuild the stepper.
- **Suggested wave or follow-up:** `T7.x`, with the stepper rebuild.
+### BL-016 — Back to a URL with no `step` leaves the wizard on the step it was on
+
+- **Found during:** T5.1
+- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
+- **What:** the popstate handler reads `parseInt(state.step, 10)` and acts only when
+ the result is `>= 1`. Going from `?project=X&step=6` back to `?project=X` yields
+ `NaN`, so nothing happens and the wizard stays on step 6 while the address bar says
+ step 1. `T4.2`'s own probe never caught it because it moves between two URLs that
+ both carry a `step`, so the NaN branch was never taken. The fix is one expression —
+ treat a missing `step` as 1 — but it is `S3`'s code and `T4.2`'s done-whens, not
+ `A4`'s.
+- **Why not now:** `CLAUDE.md` — do not fix what you notice in passing. `T5.1`'s rail
+ makes it easier to reach (ten keyboard-reachable buttons instead of ten chips) but
+ does not cause it, and folding an `S3` correction into an `A4` diff makes both
+ unreviewable.
+- **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the
+ current behaviour with a named check so the fix has a test waiting for it.
+
+### BL-017 — The native-dialog baseline metric counts prose
+
+- **Found during:** T5.1
+- **Where:** `docs/reference/file-map.md` §4, metric 1
+- **What:** the metric is `grep -ohE '\b(alert|confirm|prompt)\(' *.js *.html`, which
+ matches those words inside comments as readily as inside code. Four comments written
+ during `T5.1` — every one of them *about* removing a dialog — pushed the count from
+ 80 to 82 while the task was deleting two real calls. They were reworded, but the next
+ person to explain a dialog in a comment will move the number again, and `T5.8` and
+ wave 9 both have to drive it to a target.
+- **Why not now:** redefining a wave 0 baseline mid-plan is worse than the noise; the
+ count is comparable to itself as long as everyone measures it the same way.
+- **Suggested wave or follow-up:** `T5.8`, which owns the wizard's count, should record
+ a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
+ target against the stripped figure.
+
### BL-014 — Four controls fall back to the browser's default focus ring
- **Found during:** T3.4
diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js
index a4e951e..71c87ce 100644
--- a/html/work-package-suite-app.js
+++ b/html/work-package-suite-app.js
@@ -436,8 +436,10 @@ function loadSampleData(){
track('sample_loaded');
alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.');
-
- // Switch to step 1
+
+ // Switch to step 1. Every step now holds sample content, so the rail marks them
+ // all as visited — otherwise a fully populated wizard shows ten unstarted steps.
+ for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
currentStep = 1;
updateStepUI();
updateProjectDisplay();
@@ -468,6 +470,9 @@ function restoreSavedSOP(){
renderSequenceSteps();
renderSources();
repopulateForm();
+ // A completed SOP has been all the way through: the rail marks every step done
+ // rather than showing ten unstarted steps over a finished configuration.
+ for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
if(typeof onSOPReady === 'function') onSOPReady(sop);
}
@@ -531,9 +536,6 @@ function switchTool(tool, opts){
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
document.getElementById(`tool-${contentTool}`).classList.add('active');
- // Reset step counter
- document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
-
if(contentTool === 'wp') renderWPTab(isDash);
// Only go full-bleed when the creator is actually showing. With the SOP
// incomplete this tab shows a short 'complete the SOP first' gate; making the
@@ -1198,6 +1200,65 @@ function addSource(){
renderSources();
}
+// ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
+// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
+// three hand-written messages. The rail needs the same answer for every step, not
+// just the one you are standing on, so the list is data now: one source that both
+// the guard and the rail read. A rail that offers a step the guard then refuses is
+// worse than no rail.
+//
+// The predicate reads the DOM, not `state`. collectStepData() only ever collects
+// the CURRENT step, so `state.governance.woformat` is stale for any step you have
+// not visited — and the rail has to judge all ten. Every step's markup is in the
+// document at all times (steps are shown and hidden with display), so the fields
+// are always readable.
+//
+// T5.8 widens this to every step whose markup marks a field required, and replaces
+// the native dialog with inline errors. The shape is chosen so that is an edit to this
+// table rather than to the four functions below it.
+const STEP_GATES = {
+ 1: {fields: ['proj_name', 'proj_number', 'proj_client', 'proj_division', 'proj_site'],
+ msg: 'Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'},
+ 5: {fields: ['gov_woformat'], msg: 'Please enter a Work Package Number Format.'},
+ 6: {fields: ['qual_qcreq'], msg: 'Please select a QC requirement.'}
+};
+
+const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
+ 6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources'};
+
+// Steps you have actually been on. A stepper's tick means "done", and a step you
+// have never opened is not done however its defaults happen to read — step 6's QC
+// dropdown, for instance, is never empty.
+//
+// Deliberately NOT part of `state`: state is fingerprinted by sopIsDirty(), so
+// recording navigation in it would make merely looking at a step count as an
+// unsaved change and fire T4.3's unsaved-work guard on the way out.
+const _visitedSteps = new Set([1]);
+
+function stepGateMet(n){
+ const gate = STEP_GATES[n];
+ if(!gate) return true;
+ return gate.fields.every(id => {
+ const el = document.getElementById(id);
+ return !!(el && String(el.value || '').trim());
+ });
+}
+
+// What the rail may offer, stated as the guard already behaved rather than as
+// something stricter. goToStep()/nextStep() have one rule: you may leave the step
+// you are on once ITS required fields are filled. So the unreachable set is
+// "everything ahead of here, while here is incomplete" — nothing more elaborate.
+//
+// The temptation is to lock every step after the first unmet gate anywhere in the
+// wizard. That is a rule the guard does not enforce: with step 1 blank you can
+// still jump 3 -> 5, because validateStep() only ever looks at the step you are
+// standing on. A rail that showed a padlock the Next button then walked straight
+// past would be the drift this table exists to prevent.
+function stepReachable(n){
+ if(n <= currentStep) return true; // going back is never gated (previousStep never was)
+ return stepGateMet(currentStep);
+}
+
// ── STEP NAVIGATION ────────────────────────────────────────────────────────────
function goToStep(n, opts){
const fromUrl = !!(opts && opts.fromUrl);
@@ -1205,7 +1266,13 @@ function goToStep(n, opts){
// the forward-navigation guard. validateStep() ends in alert() when a required
// field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared
// link to step 3 opened a modal dialog before the page had finished booting.
- if(!fromUrl && !validateStep(currentStep)) return;
+ //
+ // Neither is going BACKWARDS. previousStep() has never validated, so a rail that
+ // did would trap you on an incomplete step with no way out but the Back button —
+ // and the rail's whole point is that you can move around.
+ if(!fromUrl && n > currentStep && !validateStep(currentStep)) return;
+ railMessage('');
+ _visitedSteps.add(n);
currentStep = n;
if(typeof WPAutosave !== 'undefined') WPAutosave.flush('step');
// S3: the step you are on survives a refresh and a shared link.
@@ -1228,32 +1295,173 @@ if(typeof WPUrl !== 'undefined'){
function nextStep(){
if(!validateStep(currentStep)) return;
if(currentStep < 10){
+ railMessage('');
currentStep++;
+ _visitedSteps.add(currentStep);
updateStepUI();
}
}
function previousStep(){
if(currentStep > 1){
+ railMessage('');
currentStep--;
+ _visitedSteps.add(currentStep);
updateStepUI();
}
}
+// ── THE STEP RAIL (A4 / S9) ───────────────────────────────────────────────────
+// One state per step, each told apart by a word and a marker shape as well as a
+// colour (C1). The rail is static markup; this only re-labels it.
+const _STEP_STATE_TEXT = {current: 'Current step', complete: 'Complete', locked: 'Locked', todo: ''};
+
+function railMessage(text){
+ const el = document.getElementById('step-rail-msg');
+ if(!el) return;
+ // Re-setting identical text does not re-announce, and clearing then setting in
+ // the same tick is a no-op to most screen readers. Only touch it on a change.
+ if(el.textContent === text) return;
+ el.textContent = text;
+}
+
+function stepRailState(n){
+ if(n === currentStep) return 'current';
+ if(!stepReachable(n)) return 'locked';
+ // Visited AND valid. A step you have never opened is not "complete" however its
+ // defaults happen to read — step 6's QC dropdown, for one, is never empty.
+ if(_visitedSteps.has(n) && stepGateMet(n)) return 'complete';
+ return 'todo';
+}
+
+function renderStepRail(){
+ const list = document.getElementById('step-rail-list');
+ if(!list) return;
+ list.querySelectorAll('.step-btn').forEach(btn => {
+ const n = parseInt(btn.dataset.step, 10);
+ const st = stepRailState(n);
+ btn.classList.toggle('is-current', st === 'current');
+ btn.classList.toggle('is-complete', st === 'complete');
+ btn.classList.toggle('is-locked', st === 'locked');
+ if(st === 'current') btn.setAttribute('aria-current', 'step');
+ else btn.removeAttribute('aria-current');
+ // aria-disabled, not disabled: the button stays focusable so a keyboard user
+ // can reach it and be told what is in the way. `disabled` would remove it from
+ // the tab order and from most screen readers' element lists entirely.
+ if(st === 'locked'){
+ btn.setAttribute('aria-disabled', 'true');
+ btn.title = `Finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`;
+ } else {
+ btn.removeAttribute('aria-disabled');
+ btn.removeAttribute('title');
+ }
+ const marker = btn.querySelector('.step-btn-marker');
+ if(marker) marker.textContent = st === 'complete' ? '✓' : String(n);
+ const stateEl = btn.querySelector('.step-btn-state');
+ if(stateEl) stateEl.textContent = _STEP_STATE_TEXT[st];
+ });
+ const pos = document.getElementById('step-rail-pos');
+ if(pos) pos.textContent = currentStep;
+ const here = document.getElementById('step-rail-here');
+ if(here) here.textContent = STEP_LABELS[currentStep] || '';
+}
+
+// Clicking a step you cannot reach yet says why, and puts the cursor in the field
+// that is in the way. Saying "no" and leaving you where you were, with no idea
+// which of five inputs was empty, is what the old native dialog did.
+//
+// The blocker is always the step you are standing on — that is the only thing
+// stepReachable() gates on — so there is nowhere to navigate to.
+function railBlockedClick(n){
+ const gate = STEP_GATES[currentStep];
+ railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`);
+ if(!gate) return;
+ const missing = gate.fields
+ .map(id => document.getElementById(id))
+ .find(el => el && !String(el.value || '').trim());
+ if(missing){
+ try { missing.scrollIntoView({block: 'center', behavior: 'smooth'}); } catch(e) { }
+ missing.focus();
+ }
+}
+
+function collapseRailIfNarrow(){
+ const rail = document.getElementById('step-rail');
+ const toggle = document.getElementById('step-rail-toggle');
+ if(!rail || !toggle) return;
+ // Only when the disclosure is the live control. Above the breakpoint the toggle
+ // is display:none and the list is always shown, so collapsing would set a class
+ // nothing reads and leave aria-expanded describing a control nobody can see.
+ if(!toggle.offsetParent) return;
+ rail.classList.add('is-collapsed');
+ toggle.setAttribute('aria-expanded', 'false');
+}
+
+// Every field any gate depends on, flattened once. Typing into one of these
+// changes what the rail is allowed to offer, and a rail that only refreshes when
+// you navigate is a rail that says "locked" over a form you have just filled in —
+// which is worse than the strip it replaced, because that one at least lied
+// consistently.
+const _GATE_FIELD_IDS = new Set(
+ Object.keys(STEP_GATES).reduce((all, n) => all.concat(STEP_GATES[n].fields), []));
+
+function railWatchField(e){
+ const t = e.target;
+ if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return;
+ // The refusal message is about a state that no longer holds once the field it
+ // named has been filled.
+ if(stepGateMet(currentStep)) railMessage('');
+ renderStepRail();
+}
+
+document.addEventListener('DOMContentLoaded', function(){
+ const list = document.getElementById('step-rail-list');
+ const toggle = document.getElementById('step-rail-toggle');
+ document.addEventListener('input', railWatchField);
+ document.addEventListener('change', railWatchField);
+ if(toggle){
+ toggle.addEventListener('click', function(){
+ const rail = document.getElementById('step-rail');
+ const open = rail.classList.toggle('is-collapsed') === false;
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+ if(!list) return;
+ list.addEventListener('click', function(e){
+ const btn = e.target.closest('.step-btn');
+ if(!btn) return;
+ const n = parseInt(btn.dataset.step, 10);
+ if(btn.getAttribute('aria-disabled') === 'true'){ railBlockedClick(n); return; }
+ goToStep(n);
+ collapseRailIfNarrow();
+ });
+ // Enter and Space come free with
@@ -63,21 +65,45 @@
-
-
-
-
Project
-
Team
-
Sign-Offs
-
WP Types
-
Governance
-
Quality
-
Platforms
-
Sequence
-
Constraints
-
Sources
-
-
+
+
diff --git a/tests/stepper_check.py b/tests/stepper_check.py
new file mode 100644
index 0000000..b50f18c
--- /dev/null
+++ b/tests/stepper_check.py
@@ -0,0 +1,535 @@
+#!/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. all ten steps are elements
+ 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
+
+# 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"
]*onclick", src))
+ span_onclick += len(re.findall(r"]*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. all ten steps are elements")
+ open_wizard()
+ rows = rail(page)
+ chk("the rail renders ten steps", len(rows) == 10, "found %d" % len(rows))
+ chk("...every one of them a ", 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 / 10' left in the app bar",
+ not re.search(r"\b\d+\s*/\s*10\b",
+ page.eval("(document.querySelector('.header')||{}).textContent||''")),
+ page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
+
+ # ── 6. the div-onclick baseline moved ─────────────────────────────────────
+ print("\n6. the wave 0
count dropped by 10")
+ divs, spans = source_counts()
+ chk("app-wide div-with-onclick is %d, down 10 from %d"
+ % (divs, BASELINE_DIV_ONCLICK), divs == BASELINE_DIV_ONCLICK - 10,
+ "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, 11)), [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") == "10",
+ 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, 11)),
+ [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; })()"))
+ # That call went through validateStep() directly rather than through the rail,
+ # so it hit the wizard's surviving alert. Recorded here so the fact is on the
+ # page rather than hidden, and cleared so check 8 below still means something.
+ guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
+ chk("...via the wizard's alert, which T5.8 still owns",
+ len(guard_dialogs) == 1 and guard_dialogs[0][0] == "alert", guard_dialogs)
+ page.eval("window.__dialogs = []")
+ 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 all ten steps",
+ page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
+ ".filter(b => b.getBoundingClientRect().height > 0).length") == 10,
+ 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())