a11y-test-7d30c0 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited a11y-test-7d30c0 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Pick the right execution mode from the routing table before running anything (the table is the source of truth — don't trust remembered mode counts):
| Task | Tool | Why |
|---|---|---|
| Codified CI keyboard tests, visual regression, axe-core scans, WCAG compliance suites | npx playwright test with .spec.js files | Real keyboard events, CI-runnable, version-controlled, reproducible. Primary path — all mandatory rules below still apply. |
Baseline sweep across a list of URLs — machine-readable axe-core evidence per page, no .spec.js authoring, not CI-embedded | references/baseline-url-scan.mjs (in-repo reference script; peer deps playwright + @axe-core/playwright) | Sequential per-page axe scan + summary JSON for baseline/regression evidence across many pages in one run. --census adds DOM-census heuristics (empty paragraphs, autocomplete-absence, duplicate ids); --alt-snapshot writes a diffable per-page alt-text map. Detector output, not a conformance verdict — axe-detectable subset (plus heuristics) only. See "Baseline URL-list scan" below. |
Supplemental automated coverage — run a second/third detector engine over an already-scanned page for a broader candidate net and an aggregate cross-engine signal (agreement → corroborated; never a conformance verdict) | Siteimprove Alfa (@siteimprove/* Playwright, consuming-project dep) or WebAIM WAVE (extension or subscription API, operator's own licence) | Detector-only supplemental lanes that feed detected_by / corroboration. Breadth adds candidates to triage, true and false — not a coverage guarantee. See "Supplemental detector lanes (WAVE, Siteimprove Alfa)" below. |
| Interactive agent-driven reconnaissance: snapshot ARIA structure, navigate a SPA to reach the page under test, verify a fix in place, capture annotated screenshots, probe a disclosure/menu/modal without writing a test file | agent-browser CLI (snapshot+ref pattern, persistent CDP daemon, real keyboard events) | One shell call per action, no test-file overhead, returns @e1-style refs that map directly to actions. See "Interactive reconnaissance with agent-browser" below. |
| Generate a test script from a prose spec ("test that this modal traps focus and Escape closes it") | /webwright:run or /webwright:craft (Claude Code plugin) | LLM generates complete Python Playwright script. Review before trusting. Also captures aria_snapshot() for deep ARIA tree inspection. See references/webwright-testgen.md. |
| Goal-driven journey audit of a live URL — "can a keyboard-only or screen-reader user complete this task?" — with evidence artifacts | keyboard-a11y-tester (external clone, pinned release 0.5.0; deterministic runner + agent-driven serve/step loop) | URL + goal in, evidence-linked WCAG findings out — no test file needed. Emulated screen-reader announcements, live-region capture, and focus-indicator measurement at the page/journey level that no other mode provides. See "Goal-driven journey audits with keyboard-a11y-tester" below. |
| Component/unit-level screen-reader assertions — accessible names, reading order, live-region announcements — in the project's own test suite (Vitest/Jest+jsdom, Storybook play functions, or a browser page), no URL or journey needed | @guidepup/virtual-screen-reader (npm devDependency, exact-pinned 0.32.1) | Per-component, per-PR spoken-output evidence in milliseconds — the implement→test layer keyboard-a11y-tester can't reach (it needs a deployed URL). Synthetic interactions: never keyboard-operability evidence. See references/vsr-testing.md. |
| Visual inspection, DOM queries from a conversational session | agent-browser screenshot / agent-browser screenshot --annotate / agent-browser snapshot --max-output 8000 | Same daemon, no test runner needed. |
A person confirms a fix at the fixed stage (the closure record needs its attestation block), or walks an ICT baseline row no machine mode covers | references/human-verification-walkthrough.md | The human tier: a planned-operation-driven walk that records before / action / expected / observed per operation in a shape the five admissibility rules can read, plus an attended-media shape for the alternative-content rows. Never a free walk; the one place a single PASS is the unreliable result. See "Human verification walk-through" below. |
| Anything requiring real keyboard event delivery through an MCP wrapper | DO NOT USE Playwright MCP. Its browser_press_key calls are silently dropped for most interactive widgets. Use npx playwright test or agent-browser instead. |
Decision flowchart:
Do you have a prose description of what to test, but no test script yet?
YES → /webwright:run (one-shot) or /webwright:craft (reusable parameterized tool)
NO, you need to run an existing test → npx playwright test
NO, you have a list of URLs and need machine-readable axe evidence across all of them, no test file → references/baseline-url-scan.mjs (or pa11y-ci --sitemap for sitemap-wide sweeps)
NO, you need to audit a live URL against a user goal (journey, announcements, focus indicators) → keyboard-a11y-tester
NO, you need to assert component announcements, names, or reading order in unit tests (no URL yet) → virtual-screen-reader
NO, you need to explore interactively → agent-browser
NO, a person must confirm a fix on the pinned version, or walk a baseline row no mode covers → references/human-verification-walkthrough.mdCDP keyboard event delivery for `agent-browser` has been verified end-to-end on both a vanilla JS disclosure widget (WAI-ARIA APG disclosure-faq example: focus → press Enter → aria-expanded: false → true) and a React state-driven modal (react.dev DocSearch: Meta+K → React global keydown listener → state-mounted searchbox). The MCP keyboard delivery bug does not apply to agent-browser because it calls CDP Input.dispatchKeyEvent directly rather than through an MCP wrapper.
Evidence type must match the failing condition. A screenshot is never evidence for an interaction-class fix (keyboard operability, focus behavior, or a status-message announcement) — it shows what a sighted mouse user sees, not what a keyboard or screen-reader user experiences. When a fix's evidence doesn't match its defect class, the fix ships labeled partial, naming which defect classes still lack matching evidence.
| Defect class | Evidence REQUIRED before "verified" | Mode |
|---|---|---|
keyboard-operability — Keyboard operability (reachable, operable with Tab/Enter/Space/Escape/arrows) | Real-keyboard Playwright transcript — actual page.keyboard.press() calls, never ARIA-attribute inspection alone | npx playwright test |
focus-order-indicator — Focus order & focus-visible sufficiency | Journey-level focus trace evidence | keyboard-a11y-tester |
name-role-state — Accessible name/role/state; status-message announcements | Assertion output against actual computed screen-reader output | virtual-screen-reader |
machine-detectable — Machine-detectable semantics, contrast, alt-presence (a rule fires or stops firing) | Re-scan of the touched page(s) after the fix | baseline-url-scan.mjs (axe-core violations; --census/--alt-snapshot for the heuristic classes) |
visual-only — Visual-only classes (layout, spacing, color/swatch correctness) | Screenshot comparison | screenshots (agent-browser screenshot / Playwright screenshot) |
This table is what a11y-critic Phase 0 checks a remediation's attached evidence against, and what bug-reporting's "Verification evidence" field cites.
A detector PASS means only "no detection fired for this route, state, viewport, config, and version" — never a WCAG, Section 508, keyboard, or assistive-technology verdict. Cross-tool agreement on the same target raises triage priority; it never confirms a defect by itself, and an absence of detection is not evidence of conformance. When two or more independent detectors flag the same WCAG criterion on the same target, record it on the finding as corroboration: corroborated with a detected_by engine list (the A11y Evidence Finding Contract's Cross-Detector Corroboration fields) — a triage-confidence tag, never a conformance input, and "corroborated" precisely because "confirmed" is reserved for the human/AT verification tier below. The join key is WCAG criterion + fingerprint, never rule id (engines name the same defect differently). Same-family engines (axe-core, HTML_CodeSniffer, Alfa) agree more cheaply than cross-family ones (WebAIM WAVE vs axe); weight the signal accordingly, and never count a non-detection as agreement.
An infrastructure limit must never emit a canonical result. A step-cap watchdog, a timeout, or a crashed collector is an abort, not a PASS/FAIL/BLOCKED outcome — record it as what it is (aborted, incomplete, environment-limited) and keep it out of the pass/fail denominator until it is resolved.
Mandatory cross-check rule: whenever a run's non-conclusive rate (BLOCKED, cantTell, or equivalent) approaches saturation for a batch — most of the sampled set landing in a single non-conclusive bucket rather than spread across pass/fail — treat that as a signal about the collector, not about the product, and cross-check the batch against an independent evidence lane (a different tool, a driven session, or manual sampling) before the numbers reach a client-facing report. A near-saturated non-conclusive rate that ships unchecked reads as "almost entirely untestable," which may simply be an ordinary pass/fail distribution obscured by a collector fault.
Related discipline, restated for this boundary: never promote scanner output straight to a WCAG or Section 508 verdict; never treat count-parity between two runs as completeness; never collapse cantTell / informational / skipped / blocked / untested into pass or fail — each stays a distinguishable, visible state (see the coverage-ledger vocabulary in acr-reporting's untested gate for the report-side version of the same rule).
Never overwrite an evidence run. Failed, intermediate, and superseded captures are retained beside the final result under names that state why they are not final (for example -raw-live-capture, -script-error, -modifier-mismatch, -pre-final-adjudication). The same discipline extends to generated deliverables: every non-final revision is kept beside the final one with an append-only supersession log, and each non-final revision is explicitly marked not client-facing and not a conformance, certification, publication, or acceptance artifact.
Retention is not bookkeeping for its own sake — it is what makes silent errors findable. A numeric error in an otherwise structurally valid generated deliverable — a formula range that under-counts, a mapping that drops rows — passes schema validation and surfaces only when a later revision can be diffed against the one that was wrong. Overwrite the run and that diff is gone.
references/hash-evidence.mjs makes the rule checkable: it writes an append-only checksums.json manifest beside an evidence tree and --verify lists every modified, missing, or unlisted file, exiting non-zero on drift (modified or missing; unlisted too under --strict) — it makes silent edits findable, not impossible.
Two clauses that govern when a retest result is trustworthy.
n = 1 is variance, not a finding. A single failed reproduction attempt is inconclusive, not a FAIL. A FAIL requires the same miss reproduced across two independent sessions (a different run, same conditions). This mirrors the routing rule already in force elsewhere in this bundle for model-benchmark evaluation: a single-lane result that flips under byte-identical conditions is treated as variance until adjudicated by a second, independent pass — never reported as a conclusion on its own.
A version or content-marker delta forces a fresh retest. Frozen evidence has an expiry condition tied to the product, not the evidence: the moment the product's version or a tracked content marker changes, every baseline captured before that change stops being admissible as a claim about current conformance — it remains valid history and nothing more. Two rules follow:
The retest campaign has a human tier, and it is not a free walk. When a closure record needs its attestation block (the fixed-stage confirmation acr-reporting admits an improved term on), or when a declared-508 engagement reaches one of the crosswalk's 13 not-covered rows, a named person walks entries from the campaign's planned operation set — never off it — and records, per operation, where they started and how they got there, the action, the expected result, and the observed result, in the package shape references/human-verification-walkthrough.md specifies (an operation shape, and an attended-media shape for the alternative-content rows). The record is appended to the sample's evidence artifact under the append-only rule, and the closure's attestation block cites it.
The n = 1 rule reverses at the fixed stage. A single failed reproduction is variance at diagnosis because the expensive error there is a flaky miss reported as a regression. At the fixed stage the expensive error runs the other way — one human PASS becomes a supports in a published document, and the person confirming a fix knows what they expect to see. A single human FAIL sends the item back to remediation on its own; a human PASS that will back a fixed-stage supports needs a second package by a different person, or the same person in a separate session on a later day, with at least one of the two not by the fix's author (the closure contract's second_confirmation). Only the different-person branch controls the expectation the rule names; the same-person branch controls session and environment variance and is the weaker one — prefer a different person. A walked BLOCKED or FAIL never attests: the closure stays draft_not_attested, the walk is cited, and for BLOCKED the instrument that would decide it is named. Photosensitivity-class content (2.3.1 / 2.3.2) is never walked by extended attendance — a capped first look, or a declined one, goes straight to BLOCKED with the analyzer named; the reference carries the safety clause.
A retest campaign is not complete when the runner exits — it is complete when zero planned operations remain unresolved. Treat "the suite ran" and "every planned operation has a result" as different claims until proven equal:
This is a contract for the evidence a retest run must produce, not a specification for a particular runner implementation — see docs/a11y-evaluation-report-contract.md for the report-level half of the same completeness rule.
Retest evidence is admissible for the operation it claims only when it survives five rules. These govern the evidence package for a single operation — a retest hitting a specific target, a keyboard trace, a passive DOM/AX observation — not the accessibility of the target itself. The evals/suites/a11y-test-operation-evidence lane exercises each with a clean control.
focus_stagnation_observed-class note — focus not advancing on a keyboard probe — is a bounded collector observation, not a WCAG 2.1.2 keyboard-trap finding. Promoting it to a trap conclusion requires a separate trace that attempts the documented exit (press Escape or the exit keys and show focus cannot leave). Absent that trace the operation stays BLOCKED where the stagnation observation is admitted evidence about that operation (a collector block, not a conformance outcome); stagnation alone is neither a trap nor a conformance failure.before) identity equals the terminal identity of the setup that immediately preceded it in the same session. A setup in one session and an action from a different starting locus in another do not compose into evidence about the planned operation.UNTESTED until it occurs naturally under an approved input. Inducing it synthetically — editing a response, forcing the state — does not clear coverage; it shows the message renders, not that the state is reachable in use.Human-sourced packages are scored by the same five rules. A person's walk-through package (references/human-verification-walkthrough.md) carries the fields the rules read — before.reached_by and before.locus in one session for continuity, observed_via for passive binding, target_reached for ancestor remapping, the exit attempt in action before any trap conclusion, approved inputs only for conditional states. A package missing those fields fails the rule whose predicate it cannot show: "I checked it, it's fine" has no starting locus and no action for its observation to bind to, so it breaks setup_action_continuity and passive_observation_binding as written; the tidier package with every field filled and observed: "as expected" breaks rule 4 alone, because no observed result of the action is bound to it — no sixth rule is needed for either, and the operation stays where its admitted evidence left it. The attended-media shape (audio description, captions, transcripts, and any observation about content rather than an operation) has no locus and no action, so rules 2 and 5 do not apply to it and the reference marks them so; played — the stretches actually played, with the alternative selected — is the binding field, and rule 4 is what catches a menu listing or a track label offered as evidence of the alternative. For human evidence rules 2 and 4 are structured self-report rather than a verified identity or a source allowlist; the record says so, and the second confirmation is the control.
Every admissibility review closes with one fenced yaml block a rule-based scorer checks mechanically (the score_acr.py precedent). Stable ids for the five rules above: bounded_diagnostic_not_promoted, setup_action_continuity, natural_only_conditional_state, passive_observation_binding, ancestor_remapping_review.
admissibility: ACCEPT | REJECT # ACCEPT exactly when rules_violated is empty
dispositions: {<operation id>: PASS | FAIL | UNTESTED | BLOCKED} # every operation in the package
rules_violated: {<operation id>: [<rule id>, ...]} # only operations whose evidence breaks a rule
claim_boundary: "<per operation: what the admitted evidence establishes, and what it leaves undecided about the target>"The four disposition values are this block's closed set. PASS and FAIL mean admitted evidence decides the operation's own predicate; BLOCKED means an admitted bounded observation about that operation stands without the trace or instrument reading that would decide it — a collector's focus_stagnation_observed note with no exit-path trace, or a person's "I watched the animation and cannot count flashes per second" with no analyzer reading; the definition was written around collector output and applies unchanged to an attended human observation, whose claim_boundary names the missing instrument; UNTESTED means no admitted observation bears on it. Rejected evidence never moves an operation: a recorded prior state stays, and an operation with none takes only what its admitted evidence supports. A non-conclusive run state outside the four (cantTell, skipped) is UNTESTED here and named in claim_boundary, never folded into BLOCKED. admissibility and rules_violated score the evidence package, not the target's accessibility; dispositions carries forward only what admitted evidence establishes about each operation.
Not every PASS carries the same evidence. A rule-tier pass verified the operation-specific predicate the row is about — this exact control, this exact expected result. A chain-tier pass rode a generic keyboard-chain success: the page was navigable and nothing obviously broke, with no operation-specific rule behind it. Counting the two together overstates coverage.
Partition passes into the two tiers and make the ratio visible in the evidence artifact itself, not in prose. A completeness claim reporting a single PASS count — without showing how many rested on an operation-specific rule versus a generic chain — has not established what it claims. This is a self-check on our own output quality, not a statement about the product: a chain-tier-heavy PASS set is a signal to go back and add operation-specific predicates, never a reason to report a high pass rate.
Evidence artifacts are handles, not payloads. Reference trace.json, findings.json, axe-core results, and similar artifacts by path — never paste them wholesale into context. Pull only the fields under adjudication; jq recipes for the common extractions live in references/evidence-extraction.md.
Screenshots are cited by path in findings and reports. Actually viewing one — reading its image content into context — is reserved for visual-class adjudication (the last row of the evidence table above); every other defect class is decided from extracted fields, not pixels. When a review genuinely needs to view images — a batch of visual-class captures, or a screenshot-heavy recon pass — run it in a subagent (a11y-evidence-reader, whose Vision Mode gates and budgets image reads and returns text only) so the image bytes stay in that agent's window and never reach the main session. And prefer agent-browser screenshot (writes the file to disk, returns a path) over the MCP screenshot tools (claude-in-chrome, Playwright browser_take_screenshot), which return the bytes inline: the disk path is a handle, the inline bytes are a payload.
Long-running commands (scans, test runs, crawls) redirect stdout to a file; read back only the summary or failing subset, never the full log — then pull what you need with the extraction recipes (references/evidence-extraction.md).
agent-browser calls in conversational sessions carry --max-output <chars> to cap output per call — see "Interactive reconnaissance with agent-browser" below.
For ad-hoc a11y probing inside a conversational session — before writing a .spec.js file, when verifying a single fix, or when exploring the ARIA structure of an unfamiliar component — use agent-browser. The snapshot+ref pattern eliminates locator hunting:
agent-browser open https://example.com/component-under-test
agent-browser snapshot -i --max-output 8000 # Returns interactive elements with refs: [ref=e1], [ref=e2]...
agent-browser focus @e1 # Focus by ref
agent-browser press Enter # Real CDP keyboard event
agent-browser get attr @e1 aria-expanded # Verify state mutation
agent-browser screenshot --annotate # Numbered overlays mapping to refs (useful for multimodal review)
agent-browser closeKey flags: --profile Default (reuse the user's Chrome login state for authenticated sites), --session <name> (isolated browser per parallel agent), --json (parseable output for programmatic checks), --max-output <chars> (truncate output to N characters — cap output-heavy commands like snapshot -i; a capped snapshot can silently drop element refs, so raise the cap rather than probing an inventory you know is truncated), --allowed-domains (safety).
Keyboard-driving discipline (applies to all interactive modes, this one included): never send a pre-counted sequence of Tabs. Snapshot/observe, then act on what is actually focused — "Tab until the focused control is named X" is right; "Tab 6 times" is wrong. Confirm success by state change (attribute flip, URL change, announcement), not assumption.
When to escalate to `npx playwright test`: when the verification needs to live in CI, run across PR builds, or exercise the 12 APG widget pattern templates in references/keyboard-test-patterns.md. Reconnaissance with agent-browser is for interactive probing; codified regression still belongs in .spec.js files.
When you have to adjudicate more than one scanner occurrence on a page — WCAG-EM audit sampling, a scanner gap-review lane, or re-testing a batch of findings — work the page, not the rule. A human reviewer loads a page once and reads its headings, control names, images, tables and landmarks in a single pass, then disposes of every finding on that page from that one look. Probing one element per rule (open page, check the empty button; re-open, check the missing alt; re-open, check the layout table…) is the slow anti-pattern this replaces: it re-navigates the same page many times and scatters the evidence.
The default instead: one DOM + accessibility-tree capture per bound page/state, covering every rule class at once, then adjudicate all of that page's occurrences from the single capture. references/page-audit.mjs is the reusable auditor — auditPage() runs in the page context and returns evidence for five scanner-rule classes in one call (region_missing, heading_empty, button_empty/empty-name controls, alt_missing, table_layout). Run it through whichever browser mode you are already in (Playwright page.evaluate(AUDIT_PAGE_SRC), agent-browser eval, or the in-session JS tool).
Method:
Discipline (the same rules that govern any evidence here):
accName walk applies ARIA precedence (an empty aria-labelledby target yields an empty name), but confirm an empty-name finding against the browser's OWN computed accessible name (Playwright page.accessibility.snapshot(), agent-browser snapshot -i, or the DevTools Accessibility pane) before mapping it to a criterion — that read decides 4.1.2 (empty name) vs 2.4.6 (present but non-meaningful).A finding usually names a cause: "the aria-label is overriding better visible text", "tabindex="0" on the wrapper is why tab order is wrong". A snapshot proves the attribute exists. It does not prove the attribute is why. Differential diagnosis closes that gap: remove the suspect attribute, re-observe under identical conditions, and see whether the experience actually improves.
Technique credited to SSA's YANKI bookmarklet (Accessible Solutions Branch — same team as ANDI), which does exactly this for human testers. The bookmarklet itself is not routed here: in a scripted lane the removal is one line, and scripting it means the before/after pair is capturable as evidence rather than something you saw once.
// Playwright. Baseline first — never mutate before you have captured the baseline.
const before = await page.accessibility.snapshot();
await page.evaluate(() =>
document.querySelectorAll('[aria-label]').forEach(el => el.removeAttribute('aria-label'))
);
const after = await page.accessibility.snapshot();
// The diff is the evidence. Attach both halves, not the conclusion.What each removal tests, and what a positive result means:
| Remove | If the result improves, the finding is |
|---|---|
aria-label / aria-labelledby | the accessible name is overriding better visible text (2.5.3, 4.1.2) |
role | an applied role is fighting the element's native semantics |
tabindex > 0 | the author-imposed order is why focus order is illogical (2.4.3) |
tabindex="-1" on an interactive control | the control is being held out of the tab order (2.1.1) |
tabindex="0" on a non-interactive element | tab-order noise, not a real stop |
aria-hidden | content is hidden from assistive technology that should not be |
Rules:
Not yet verified: the agent-browser equivalent of the page.evaluate step. The Playwright form above is the verified route; anyone wiring the agent-browser lane should confirm its own subcommand against --help first rather than assuming one exists.
Generate Playwright test scripts from prose specs via the Webwright plugin. See references/webwright-testgen.md.
When to use: you have a live URL and a task in plain words ("can a keyboard-only or screen-reader user complete X?") and need evidence-linked WCAG findings without writing a test file — discovery audits, before/after patch evidence, whole-journey reviews. When NOT to use: widget CI regression (→ .spec.js + the APG templates in references/keyboard-test-patterns.md), quick probing or authenticated Chrome-profile flows (→ agent-browser), rule scans (→ axe-core, §4).
What it is: ezufelt/keyboard-a11y-tester — an external tool, adopted at release 0.5.0 (commit 7e852a7, MIT; originally adopted at 97eb13e, bumped 2026-07-11 after upstream merged our PR #7 and began tagging releases — re-verify on every upgrade). Two layers: a deterministic Playwright/CDP runner (real keyboard events only, never .click(); machine-decidable WCAG checks; dual-signal focus-indicator measurement) and an emulated screen-reader persona (@guidepup/virtual-screen-reader: announcement capture, live-region monitoring, reading-order census). Runs both W3C personas (keyboard "Ade", screen-reader "Lakshmi") in one pass. As of 0.5.0 it also detects broken ARIA ID references and keyboard-focusable controls missing from the accessibility tree, includes our 3.3.2 UA-default-name check, and supports authenticated runs via --storage-state <playwright-storageState.json> (agent-browser remains the route when you want to reuse the user's real Chrome profile instead of exporting state). Cross-validated against this repo's 33 critic fixtures on 2026-07-10 — agreement record: evals/results/keyboard-a11y-tester/README.md.
git clone https://github.com/ezufelt/keyboard-a11y-tester && cd keyboard-a11y-tester
git checkout 0.5.0 # adopted pin (tagged release)
npm install && node scripts/setup-check.mjs # npx playwright install chromium only if browser_available=falseThe upstream Claude Code plugin flow (/plugin marketplace add ezufelt/keyboard-a11y-tester) exists but is unverified here; the clone path works from both Claude Code and Codex.
# Batch blind Tab-crawl (unattended; never presses Enter/Space) — per viewport:
node scripts/runner.mjs --url https://site --viewport desktop --max-steps 40 --out <dir>
# Driven session (the value center — the agent decides every keystroke):
node scripts/runner.mjs serve --url https://site --goal "find and submit the contact form" \
--viewport desktop --port 9333 # default port is 9333 (9400 in upstream examples is just an example)
# prints: READY <session-dir>
node scripts/runner.mjs observe <session-dir>
node scripts/runner.mjs step <session-dir> --press Tab # one key → AX name/role/state, focus style, sr_announcement
node scripts/runner.mjs step <session-dir> --type "[email protected]"
node scripts/runner.mjs finish <session-dir> && node scripts/runner.mjs stop <session-dir>Core discipline: observe → decide → act (see the keyboard-driving rule in the agent-browser section — it originated here). Read sr_announcement.live_announcements after any action that visibly changes the page: an entry proves the update reaches a screen reader; its absence after a visible change is 4.1.3 failure evidence.
--out)trace.json — per step: keystroke, selector, CDP AX name/role/state, computed focus style, focus_moved, screenshot ref, sr_announcementdeterministic-findings.json — {wcag, persona, conformance_level, confidence, severity, url, locations, persona_impact, evidence[]}screen-reader-census.json — whole-page reading order (spoken phrase, role, selector) + declared live regionsscreenshots/step_NNNN.png — focused-region cropsThese are measured test evidence for a11y-critic reviews (formal Phase 0 tier wiring lands with assessment Phase 3).
live_announcements.<input type=file> reports AX name "Choose File", so the unnamed-control check stays quiet. Label association still needs axe/static/judgment review.97eb13e and 0.5.0) — one more reason never to gate on it.AAA; every other finding falls through level || 'AA' to "AA", mislabeling the nine Level A SCs the checks cover (1.1.1, 1.3.1, 1.4.1, 2.1.2, 2.4.1, 2.4.3, 3.2.1, 3.3.2, 4.1.2) — 2.4.7 and 4.1.3 are coincidentally correct. Read the field only as pass-fail (AA) vs informative (AAA) and derive the SC's true level from the SC number. Delete this rule when the pin advances past a fix for #27.serious → MAJOR (CRITICAL if it blocks the goal); moderate → MINOR or MAJOR by user impact; minor/AAA-informative → ENHANCEMENTid embeds the viewport and is run-scoped — don't use it)keyboard → keyboard_motor; screen-reader → screen_reader_semantictrace.json step_0003: outline 3px solid; AAA contrast 2.34)navigator.webdriver (page-scoped) so the CAPTCHA can initialize — automation-signal spoofing that can trip a WAF or security review. Get explicit client sign-off before pointing it at client production infrastructure; prefer staging.npx playwright test.Component/unit screen-reader assertions (accessible names, reading order, live-region announcements) via @guidepup/virtual-screen-reader. See references/vsr-testing.md.
When to use: you have a list of URLs — a spot-check set, a sampled route list from a discovery or audit-scope engagement, a client's page inventory — and need machine-readable axe-core evidence across all of them in one run, without authoring a .spec.js file per page or wiring CI. Promoted 2026-08-14 from a one-off evidence harness written for the 2026-08-13 EPA public-sites engagement (the zivtech/a11y-audits repo (private), 2026-08-13-epa-public-sites/evidence/harness/audit-pages.mjs, run against 40 views) into a reusable, generalized reference script: references/baseline-url-scan.mjs. When NOT to use: a single page or component you're actively developing against (→ .spec.js + the APG templates in references/keyboard-test-patterns.md, or agent-browser for quick probing); keyboard operability or screen-reader announcement evidence — this mode never presses a key or captures an announcement (→ keyboard-a11y-tester or virtual-screen-reader); a sitemap-wide sweep where a maintained, hosted CI tool fits better than an in-repo script (→ pa11y-ci, below).
What it is: a plain Node script depending only on playwright and @axe-core/playwright — peer dependencies installed in your own project, never in this repo (this bundle stays prompt-only; see this repo's CLAUDE.md). It launches one Chromium browser, visits each URL sequentially with a polite delay between requests, scans with axe-core scoped to this bundle's WCAG 2.2 AA default tag set at each configured viewport — default 1280x800,320x800, matching the EPA harness's desktop+narrow lineage, override with --viewports WxH[,WxH...] — and writes a per-URL JSON result keyed by viewport (rule id, impact, node count, up to 3 sample selectors per viewport) plus an aggregated summary.json (violation counts by impact, violations by rule across all pages and viewports). It deliberately drops the EPA harness's other engagement-specific extras — full structure inventory, ARIA snapshot, keyboard tab-trace, text-spacing reflow probe, per-node XPath — to stay a focused baseline tool; reach for agent-browser or keyboard-a11y-tester when you need those.
Two opt-in flags add non-axe signals, implemented in the sibling references/census.mjs:
census key on every viewport record, always separate from axe's violations/incomplete and always labeled detector heuristics, never a conformance verdict: empty paragraphs (no text, no element children), autocomplete-absence (inputs whose type/name/label suggest a WCAG 1.3.5 known purpose but carry no autocomplete attribute), and duplicate ids (an id used on more than one element).alt-snapshot.json: one entry per URL, each a sorted list of every img/svg[role=img]'s selector, src_or_title, and alt. Captured once per page (first viewport only — alt text doesn't vary by viewport).npm install -D playwright @axe-core/playwright # peer deps — install in your own project, never in this repo
npx playwright install chromium
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --urls-file urls.txt --out ./baseline-scan-output
# or pass URLs directly, with a custom viewport list:
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --out ./out --viewports 1280x800,320x800 https://example.com https://example.org/page
# with the DOM-census heuristics and an alt-text snapshot:
node .claude/skills/a11y-test/references/baseline-url-scan.mjs --out ./out --census --alt-snapshot https://example.comurls.txt: one absolute http(s):// URL per line; blank lines and #-prefixed lines are ignored. Raise --delay (default 500ms) for rate-limited or robots-restricted targets — confirm you're authorized to test the target and check its robots.txt/terms of service before scanning a third party's production site. Exact-pin @axe-core/playwright in your own project's package.json, since rule availability is per-axe-core-version — the resolved version is recorded as axe_core_version in summary.json for exactly this reason.
Catching silent alt-text regressions: run with --alt-snapshot before and after a change, then diff the two alt-snapshot.json files —
node references/baseline-url-scan.mjs --out ./before --alt-snapshot https://example.com/page
# ...make your change...
node references/baseline-url-scan.mjs --out ./after --alt-snapshot https://example.com/page
diff before/alt-snapshot.json after/alt-snapshot.jsonAny diff line is either an intentional content update or a silent regression — a human confirms which.
summary.json (or alt-snapshot.json for alt-text specifically).--census checks are heuristics one level below even axe's rules — pattern-matches on markup shape and naming, not accessibility-tree computation — so their false-positive rate is higher by design; triage every census hit by hand before filing it.Classify challenge/block detection — don't substring-match. A naive check for words like "challenge" or a CDN vendor's name produces false stops on ordinary page content that happens to mention them. Classify instead: a confirmed HTTP 429, or a strong structural match for a known challenge page (not just a keyword), is what triggers a stop. A weak or ambiguous signal (a marketing paragraph naming a security vendor, a support article about outages) must not halt collection.
A confirmed block is a global stop, not a per-page skip. When challenge detection does classify a block, stop the entire run rather than skipping just the affected URL — a block on one page is evidence the whole session or IP is affected, and continuing to hit other pages under the same condition wastes the run and can worsen the block.
Route-settling for dynamic targets. A dynamic (SPA/client-routed) page's inventory of interactive targets may only be trusted once the route has settled: at minimum, ≥3 stable samples of the target set, an unchanged final URL, and document.readyState === 'complete'. Reading targets before settling risks acting on a transient loading state rather than the real page — this applies to any dynamic-route collection, including interactive reconnaissance with agent-browser, not only this script.
For a sweep driven by a sitemap rather than a hand-maintained URL list, route to pa11y-ci instead of adding sitemap discovery to this script:
npx pa11y-ci --sitemap https://example.com/sitemap.xml --runner axe --runner htmlcsSame adoption boundary as keyboard-a11y-tester and virtual-screen-reader: a routed external tool the operator installs in their own project, never vendored into this repo.
When to use: run a second or third independent detector over a page — beyond the primary axe-core + HTML_CodeSniffer-via-pa11y-ci lanes — for two reasons, broad automated coverage and aggregate signal, not to rank one engine against another:
corroboration: corroborated with a detected_by list — raised triage confidence, never a verdict (see the Detector-lane authority boundary above). The aggregate is also a source to mine, since engines serialize different detail (Alfa carries computed contrast ratios, WAVE selectors + contrast, axe its own node data).Both are detector-only supplemental lanes — never keyboard, screen-reader, WCAG, or Section 508 verdicts. How much unique true-positive coverage the extra engines add over axe alone is not quantified here, and need not be for the lanes to earn their place as breadth + aggregate signal (the one Alfa measurement, 2026-09, found only a small unconfirmed delta). Reopen triggers for treating either as a core stack member live in the Alfa scan adoption assessment and the WAVE adoption assessment under docs/.
Routed, never vendored. Neither engine is a dependency of this repo. Install and run them in the consuming audit or project, then merge each engine's detections into one A11y Evidence Finding Contract finding (source, detected_by, corroboration) — fingerprint the merged finding once (never per engine, or cross-engine matches never collide), keep cantTell distinct from pass/fail, and keep WCAG criterion + level separate from the detector's own result.
Open-source ACT-rules engine (MIT). Install exact-pinned in the consuming project — never in this repo:
npm install --save-dev --save-exact \
@siteimprove/[email protected] \
@siteimprove/[email protected]Record both the wrapper version and the engine version (@siteimprove/alfa-rules, 0.119.0 at this writing) — they drift independently. Level separation is load-bearing: on real pages most Alfa failures are AAA or advisory, not WCAG 2.2 AA — classify every outcome (WCAG_2_2_AA / AAA / ADVISORY / UNMAPPED) and let only AA-scoped outcomes enter the AA queue, still pending independent review. To reach a WCAG criterion from an outcome, join rule.uri against the default export of @siteimprove/alfa-rules and read each rule's requirements where type === "criterion". Alfa's default JSON carries no selector — only an internal serialization id — so a finding needs extra DOM-target serialization before it satisfies the contract's evidence field.
Commercial tool; the licence is the operator's, not the suite's. Three modes, in priority order:
WAVE_API_KEY from the environment. The key travels in the request URL by WAVE's design, so it must never be recorded — redact it from any logged URL, log line, evidence file, and error. Credit-metered under the operator's own subscription (100 free credits, then per-credit pricing); no more than two simultaneous requests; `SKIPPED_CREDENTIAL_REQUIRED` when the key is absent, never a clean result. Use reporttype=4 for CSS selectors + contrast data.Treat error / contrast / alert as candidate findings and feature / structure / aria as informational unless independent review finds a defect. Map each WAVE item to its WCAG success criterion via WebAIM's documented item→SC mapping; an item that cannot be mapped to a criterion cannot corroborate — it stays single.
Disclaimer (required wherever WAVE evidence appears): this lane does not authorize WAVE credit purchases, account creation, or credential use. WebAIM's terms do not restrict WAVE to non-commercial use; the operative restriction is that selling or redistributing WAVE reports or WAVE-derived data (error counts, listings) — or modifying, copying, licensing, or creating derivative works from WAVE — requires WebAIM's prior permission (https://wave.webaim.org/terms and https://wave.webaim.org/api/, accessed 2026-09-14). So an engagement may retain private, access-controlled evidence and independently validate each defect, but external reporting describes the independently verified defect, not the proprietary WAVE report data.
MANDATORY: All keyboard tests MUST use real Playwright keyboard interactions against a live or local site. Never check ARIA attributes alone and claim a keyboard test passed — you must actually press keys and verify the result.
The full keyboard testing detail — required testing method, the 12 WAI-ARIA APG widget-pattern templates, SPA-specific patterns, CSS anti-patterns, and ARIA supplement checks — is in references/keyboard-test-patterns.md.
Run these tests when <video>, <audio>, or media player components are present.
<track kind="captions"> exists on every <video> with speech<track> has valid src pointing to caption file| Screen Reader | Browser | Mode |
|---|---|---|
| NVDA | Chrome | Browse mode + Focus mode |
| VoiceOver | Safari (macOS) | Web rotor + standard navigation |
| (Optional) JAWS | Chrome/Edge | Virtual cursor + Forms mode |
<main>, <nav>, <header>, <footer> announced correctly<nav> elements have distinguishing aria-label<h1> presentaria-describedby reads after labelaria-live="polite" announces after current speecharia-live="assertive" interrupts; toast content announced without focus movingVisual regression testing — screenshot baselines, dynamic-content handling, contrast, zoom/reflow. See references/visual-regression-testing.md.
Inject axe-core into live pages via Playwright for automated WCAG violation detection. This catches issues that manual review misses (computed contrast through CSS layers, missing ARIA on dynamically rendered content, landmark coverage).
// In a Playwright test file (.spec.js)
const { test, expect } = require('@playwright/test');
const fs = require('fs');
test('axe-core accessibility scan', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
// Inject axe-core
const axeSource = fs.readFileSync(
require.resolve('axe-core/axe.min.js'), 'utf-8'
);
await page.evaluate(axeSource);
// Run audit
const results = await page.evaluate(() =>
axe.run(document, {
runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice']
})
);
// Report violations
const violations = results.violations;
if (violations.length > 0) {
const report = violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length
}));
console.log('axe violations:', JSON.stringify(report, null, 2));
}
expect(violations.length).toBe(0);
});For sites with multiple routes, scan each page variant:
After the axe-core scan, use findings to prioritize manual testing effort:
For large sites, classify pages into template groups and scan one representative per group:
discover phase: list all routes, group by template (list page, detail page, form page, etc.)For audit-scope runs (conformance audits, pre-VPAT work), extend template sampling with WCAG-EM sampling discipline (Steps 3–4 of WCAG-EM 2.0; verified reference: docs/wcag-em-2-reference.md):
When the sampling documentation is serialized rather than written as prose, Appendix A of docs/a11y-evaluation-report-contract.md shows one way to shape it — a non-normative example, not a schema. Two parts of it are worth carrying even when you write prose: record the actions a complete process needs (a URL alone does not identify a sample inside a process), and identify kiosk, native, and document samples by path description and/or screenshot, since they have no URL.
Report axe-core results alongside keyboard and visual regression results:
## axe-core Scan Results
Pages scanned: [count]
Total violations: [count]
Critical: [n] | Serious: [n] | Moderate: [n] | Minor: [n]
### Violations by Rule
| Rule ID | Impact | Description | Pages | Elements |
|---------|--------|-------------|-------|----------|
| color-contrast | serious | Elements must meet color contrast | 3 | 12 |This output feeds directly into the a11y-critic's Phase 0 (Consume Test Evidence) — measured violations become hard evidence in the design review.
When a test produces a failing keyboard, axe-core, visual, static-analysis, or manual finding, include an A11y Evidence Finding block for each issue that should be handed to a11y-critic or perspective-audit. Do not emit placeholder contracts for passing checks or clean fixtures.
Use these fields when evidence exists:
### A11y Evidence Finding
finding_id: stable lowercase id, e.g. a11y_form_error_describedby
fingerprint: stable 8-64 char hex hash derived from component/target/rule, not the crawl URL alone
source: test command, test file, axe rule id, agent-browser ref, or observed artifact
wcag_or_apg: WCAG 2.2 criterion or WAI-ARIA APG pattern citation
section_508_fpc_context: Section 508 context only when applicable; Revised Section 508 maps web conformance to WCAG 2.0 Level A/AA
severity: CRITICAL | MAJOR | MINOR | ENHANCEMENT
perspective_alarms: screen_reader_semantic=LOW|MEDIUM|HIGH; keyboard_motor=LOW|MEDIUM|HIGH; etc.
evidence: file:line, DOM excerpt, axe node, screenshot, keyboard trace, or measured value
reproduction_steps: commands or user steps needed to reproduce
expected_behavior: what the user or assistive technology should experience
actual_behavior: what the test observed
trend: new | persistent | worsening | improving | resolvedGuidelines:
perspective-audit.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.