playwright-a11y-e2e
End-to-end browser tests with Playwright and axe-core: axe accessibility scans, keyboard-only navigation checks, responsive breakpoint checks, and stable-selector rules. Use when writing or reviewing
SKILL.mdskills/engineering/playwright-a11y-e2e/SKILL.md
GitHubStandard: name/description/length rules — agentskills.io standard.
Playwright A11y E2E Skill (body <500 lines; full procedure in references/playwright-a11y-e2e-procedure.md)
When to use this skill (trigger)
- You need automated E2E coverage of a web app.
- You need accessibility conformance evidence (axe scans, keyboard-only paths).
- You need responsive breakpoint checks.
- You are choosing selectors that survive refactors.
Disambiguation
- Fires: automated E2E coverage, accessibility conformance evidence, responsive breakpoint checks, choosing refactor-proof selectors.
- Does NOT fire for: a static unit test of a pure function, a design-token audit (use foundation-v2-conformance), or a non-web target.
When NOT to use this skill
- Unit or API tests (use pytest or contract tests).
- Static HTML/CSS inspection (use axe-core directly).
- Sites that cannot be automated (CAPTCHA, auth wall with no test credentials).
- A single static page with no interactive elements — axe will pass trivially, but the keyboard and responsive loops are still worth running once; the skill fires on the first interactive route.
- "Just eyeball it in the browser" — the skill fires when someone asks for evidence that a human could also produce by looking; the difference is the committed artifact.
- An axe run that is only run on failure — run on every route; a green pass on a route you did not scan is not evidence.
Decision table — test strategy
| Goal | Primary tool | Evidence |
|---|---|---|
| Accessibility conformance | axe-core via Playwright | axe JSON report |
| Keyboard-only navigation | Playwright keyboard API | video + log |
| Responsive breakpoints | Playwright viewport loop | screenshot set |
| Stable selectors | getByTestId first; getByRole/getByLabel for roles |
selector audit |
Numbered procedure (summary; full recipe in references/playwright-a11y-e2e-procedure.md)
- Set up the browser context — install Playwright browsers, set a stable viewport, disable animations.
- Run axe scans — @axe-core/playwright on each route; collect violations.
- Verify keyboard-only paths — tab through every interactive element; assert focus order.
- Check responsive breakpoints — loop over configured viewports; assert no overflow.
- Use stable selectors — prefer role/label/text; never XPath or CSS classes.
- Record evidence — axe JSON, screenshots, and a selector audit.
Decision table — selector choice
| Selector | Safe? | When to pick | Pitfall |
|---|---|---|---|
page.getByTestId('close') |
Always safe | First choice for anything with a test id | Requires the data attribute to exist |
page.getByRole('button', { name: 'Close' }) |
Always safe | Roles with accessible names | Name must match exactly; locale-sensitive |
page.getByLabel('Email') |
Always safe | Form fields with a label association | Breaks if the label is removed |
page.getByText('Sign in') |
Use with caution | Visible text only; no markup inside | Matches partial text; flaky on i18n |
.btn:nth-child(3) |
NEVER | — | Breaks when the DOM order changes |
css=.card > div |
NEVER | — | Class-only; breaks on every refactor |
xpath=//button[2] |
NEVER | — | XPath is order-dependent and unreadable |
Worked examples
Good (stable selector + axe + keyboard):
import { test, expect } from '@playwright/test';
import axe from '@axe-core/playwright';
test('login form is accessible', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('a@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();
const results = await axe.scan(page.locator('main'));
expect(results.violations).toEqual([]);
});
Bad (unstable selector — breaks when the DOM order changes):
await page.click('.btn:nth-child(3)'); // which button is this, exactly?
Bad (axe on a closed modal — the hidden content is never scanned):
await axe.scan(page); // modal is aria-hidden; its issues are invisible
// fix: wait for the dialog to be visible first
await page.getByRole('dialog').waitFor({ state: 'visible' });
const results = await axe.scan(page.getByRole('dialog'));
Bad (keyboard test that skips shadow DOM — fails silently on Web Components):
await page.keyboard.press('Tab'); // stops at the light DOM; shadow controls unreachable
Pitfalls from research
- axe on dynamic modals misses content rendered after aria-hidden flips; wait for aria-hidden=false.
- CSS :focus-visible is not a WCAG criterion; do not assert on it.
- nth-child selectors break on every refactor; prefer
getByTestId, thengetByRole/getByLabel. - Keyboard-only tests that skip shadow DOM fail silently on Web Components.
- Viewport loop must include 320px, 768px, 1024px, 1440px minimum (common trap: only testing desktop).
- axe run without frame handling misses content in iframes (common trap).
Near-miss triggers (act before the incident)
- axe run on a dynamic modal without waiting for
aria-hidden=false→ false violations or missed violations. :focus-visibleasserted as a WCAG criterion → it is not one; assert focus order instead.nth-childor class-only selectors → break on every refactor; usegetByTestId(orgetByRole/getByLabelfor roles).- Keyboard tests that skip shadow DOM → fail silently on Web Components.
- Viewport loop missing 320px → mobile bugs never caught.
- axe run without frame handling → content in iframes is never scanned.
- "The axe scan is green, so the page is accessible" → green axe means zero reported violations, not that every control is reachable; run the keyboard check too.
Verification checklist (all must pass before you finish)
- axe scan runs on every route with zero violations of level A/AA [WCAG].
- Keyboard-only path covers every interactive element [WCAG].
- Responsive breakpoints pass at configured widths [responsive].
- Selectors are role/label-based, not XPath or CSS-class [stable selectors].
- Evidence files (axe JSON, screenshots) are committed or referenced [evidence].
References
- Full procedure, axe version table and worked examples:
references/playwright-a11y-e2e-procedure.md. - Eval prompts with expected behaviour and failure signs:
references/EVALS.md. - Primary sources with URLs and retrieval dates:
references/SOURCES.md.
Related skills
- acceptance-harness — axe JSON and screenshots are the evidence pointers.
- honest-status — a green axe scan is not DONE.
- long-test-discipline — the axe scan and the keyboard traversal are long tests; run them in the foreground with a recorded seed.
- module-contract-first — the axe/keyboard contract is a module contract: enumerate the selectors and roles, version the snapshot, block merge on drift.
- shared-tree-git — the evidence artifacts and the test script are committed with a task-id prefix.