Your First Single-Journey Accessibility Gate
The fastest way to get an accessibility gate blocking real regressions is to wire one pytest that drives a single critical journey — log in, land on the dashboard — runs axe-core against the settled page, and fails only on critical or serious impacts. This resolves the paralysis of “we can’t gate until we’ve scanned everything”: you gate one journey today, prove the mechanics, and grow the coverage from there.
This guide is the smallest concrete starting point under the baseline scanning setup guide, itself part of the accessibility compliance baseline for enterprise web ops section. Where the parent guide sets up a multi-route manifest and a fingerprinted snapshot, this page deliberately strips all of that away to the one test that gets a gate live in an afternoon.
When This Applies
Use this pattern when you have no accessibility gate at all and need one running before the end of the day. It fits when:
- You can identify exactly one journey whose breakage is unambiguously release-blocking — usually authentication into the primary authenticated surface.
- You want to prove the scan-and-gate mechanics end to end before investing in a route manifest, snapshot fingerprinting, or sharding.
- You are willing to gate on
critical/seriousonly for now, accepting thatmoderate/minorfindings are tracked elsewhere rather than blocked.
If you already run several routes, skip this and go straight to the manifest-driven baseline. And if the page you scan renders clean when it is visibly broken, that is a DOM-hydration race handled by Playwright headless scanning workflows, not a gating problem — fix stabilization first.
Minimal Reproducible Example
The instinct is to scan the login URL directly and call it done. That gates the wrong page: the login form is a handful of fields, and the journey that actually matters — the authenticated dashboard behind it — never gets evaluated.
# test_gate_naive.py — scans the wrong surface.
from playwright.sync_api import sync_playwright
def test_login_page_is_accessible():
with sync_playwright() as p:
page = p.chromium.launch().new_page()
page.goto("https://staging.example.com/login")
# Only the login form is evaluated; the dashboard behind it is never scanned,
# and there is no severity discrimination, so a decorative nit blocks a deploy.
results = page.evaluate("axe.run()") # also: axe was never injected
assert not results["violations"]Two defects: the test never traverses the journey past login, so the dashboard is invisible to the scan, and it blocks on any violation of any impact, which makes the gate too brittle to survive a sprint.
Correct Implementation
Drive the actual journey, stabilize the post-login DOM, inject the pinned engine, then filter to the impacts that justify blocking a release.
# test_login_dashboard_gate.py — gates ONE journey on critical/serious only.
import pytest
from playwright.sync_api import sync_playwright
BLOCKING = {"critical", "serious"}
def scan_after_login(page):
"""Walk login -> dashboard, settle the DOM, run the pinned axe engine."""
page.goto("https://staging.example.com/login", wait_until="networkidle")
page.fill("#email", "a11y-bot@example.com")
page.fill("#password", "not-a-real-secret") # a seeded, non-privileged test account
page.click("button[type=submit]")
# The journey's real surface is the dashboard: wait for a route-specific
# landmark so the scan runs against a settled page, not a login spinner.
page.wait_for_url("**/app/home", wait_until="networkidle")
page.wait_for_selector("main#dashboard", state="visible")
# Inject the exact bundled engine (not an extension) for reproducibility.
page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return page.evaluate(
"() => axe.run(document, {runOnly: {type: 'tag', values: ['wcag2a','wcag2aa','wcag22aa']}})"
)
def blocking_violations(results):
"""Keep only the impacts that justify failing a release."""
return [v for v in results["violations"] if (v.get("impact") or "minor") in BLOCKING]
def test_login_to_dashboard_gate():
with sync_playwright() as p:
browser = p.chromium.launch()
# Fix parity so target-size (SC 2.5.8) and contrast (SC 1.4.3) are stable.
context = browser.new_context(viewport={"width": 1280, "height": 800}, locale="en-US")
page = context.new_page()
results = scan_after_login(page)
browser.close()
hard = blocking_violations(results)
assert not hard, "Blocking a11y findings on login->dashboard: " + ", ".join(
f"{v['id']}({v['impact']})" for v in hard
)The gate reads impact straight from the engine; which rules run and how they map to success criteria is set once in the axe-core enterprise configuration. Filtering to critical/serious here — rather than raising a numeric budget — keeps the gate credible: a link-name or image-alt failure on the primary journey blocks, while a low-impact region nit does not.
Pipeline Integration Note
This single test is the seed of the larger baseline. Once it is green in CI, the next move is to stop hard-coding the route and read it from the route manifest described in the parent baseline scanning setup guide, then swap the raw critical/serious filter for a fingerprinted snapshot so pre-existing debt is accepted and only new regressions block. From there, blocking findings route into the error categorization and triage pipelines so a failing gate produces an owned ticket rather than a wall of JSON. The single journey grows into a manifest, the manifest grows into a sharded run, but the decision logic proven here stays the same.
Gotchas
- Live login is the flakiest link in the gate. A scripted email/password submit on every run adds a rate-limited, credential-dependent dependency that fails intermittently. As soon as the gate is stable, replace the login steps with a reused
storage_state, as covered in scanning authenticated application states. - Scanning before the dashboard settles gates a spinner. If the scan fires on
networkidlealone without waiting formain#dashboard, axe evaluates a loading skeleton and reports a misleadingly clean page. Always wait for a route-specific landmark, not just navigation. - Viewport drift flips the result between laptop and CI. A developer reproducing at native width and a runner fixed at
1280x800will disagree ontarget-sizeand reflow findings. Pin the viewport in the context so a “works on my machine” dispute cannot start.
Frequently Asked Questions
Why gate only critical and serious for a first journey?
Those two impact levels map to breakage that genuinely blocks assistive-technology users — keyboard traps, missing accessible names, unlabeled controls — so failing on them is defensible without exception. Carrying moderate and minor into the same hard gate would block releases on decorative nits and get the whole step disabled before it earns trust.
Should I scan the login page or the page behind login?
The page behind login, because that authenticated surface is the journey that actually matters and carries the bulk of the interactive components. Scanning only the login form evaluates a few fields and gives false confidence that the dashboard your users spend their time on has been checked at all.
How do I grow this one test into full coverage?
Externalize the route into a manifest, add the remaining critical journeys as entries, and replace the flat critical/serious filter with a fingerprinted baseline snapshot so existing debt is accepted while new regressions still block. The parent baseline-scanning-setup guide walks through the manifest and snapshot mechanics that this single test is a stepping stone toward.
Can I keep using a hard-coded test password in the gate?
Only for a seeded, non-privileged account on a non-production environment, and only as a temporary bootstrap. The durable pattern is to capture an authenticated session once and reuse its storage state, which removes the live credential exchange from every run and stops the login flow from being your gate’s most frequent failure.