fugazi/a11y-playwright-testing
Accessibility testing for web applications using Playwright (@playwright/test), TypeScript, and axe-core. Use to write, run, or debug WCAG 2.2 AA checks, keyboard and focus tests, ARIA/semantic validation, accessible names, form labels, color contrast, or screen-reader test patterns. Keywords: accessibility, WCAG, axe-core, keyboard navigation, focus management, ARIA.
npx skills add https://github.com/fugazi/test-automation-skills-agents --skill a11y-playwright-testing
Comprehensive toolkit for automated accessibility testing using Playwright with TypeScript and axe-core. Enables WCAG 2.2 Level AA compliance verification (superset of 2.1), keyboard operability testing, semantic validation, and accessibility regression prevention.
> Activation: This skill is triggered when working with accessibility testing, WCAG compliance, axe-core scans, keyboard navigation tests, focus management, ARIA validation, or screen reader compatibility.
accessibility-selenium-testing).playwright-e2e-testing).| Requirement | Details |
| ----------- | ------------------------------ |
| Node.js | v18+ recommended |
| Playwright | @playwright/test installed |
| axe-core | @axe-core/playwright package |
| TypeScript | Configured in project |
# Add axe-core to existing Playwright project
npm install -D @axe-core/playwright axe-core
Before writing accessibility tests, clarify:
> [!] Critical: Automated tooling can detect ~30-40% of accessibility issues. Use automation to prevent regressions and catch common failures; manual audits are required for full WCAG conformance.
Prefer native HTML semantics over ARIA. Use ARIA only when native elements cannot achieve the required semantics.
// [ok] Semantic HTML - inherently accessible
await page.getByRole("button", { name: "Submit" }).click();
// [no] ARIA override - requires manual keyboard/focus handling
await page.locator('[role="button"]').click(); // Often a <div>
If you cannot locate an element by role or label, it's often an accessibility defect.
| Locator Success | Accessibility Signal |
| -------------------------------------------- | -------------------------- |
| getByRole('button', { name: 'Submit' }) [ok] | Button has accessible name |
| getByLabel('Email') [ok] | Input properly labeled |
| getByRole('navigation') [ok] | Landmark exists |
| locator('.submit-btn') [!] | May lack accessible name |
import AxeBuilder from "@axe-core/playwright";
import { test, expect } from "@playwright/test";
test("page has no WCAG 2.2 AA violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("form component is accessible", async ({ page }) => {
await page.goto("/contact");
const results = await new AxeBuilder({ page })
.include("#contact-form") // Scope to specific component
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("form is keyboard navigable", async ({ page }) => {
await page.goto("/login");
// Tab to first field
await page.keyboard.press("Tab");
await expect(page.getByLabel("Email")).toBeFocused();
// Tab to password
await page.keyboard.press("Tab");
await expect(page.getByLabel("Password")).toBeFocused();
// Tab to submit button
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused();
// Submit with Enter
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/dashboard/);
});
test("dialog traps and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "Delete account" });
// Open dialog
await trigger.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Focus should be inside dialog
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
// Tab should stay trapped in dialog
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: "Confirm" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
// Escape closes and returns focus to trigger
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
test("skip link moves focus to main content", async ({ page }) => {
await page.goto("/");
// First Tab should focus skip link
await page.keyboard.press("Tab");
const skipLink = page.getByRole("link", { name: /skip to (main|content)/i });
await expect(skipLink).toBeFocused();
// Activating skip link moves focus to main
await page.keyboard.press("Enter");
await expect(page.locator('#main, [role="main"]').first()).toBeFocused();
});
| Principle | Focus Areas | Example Tests |
| ------------------ | ----------------------------------------- | ------------------------------------------------- |
| Perceivable | Alt text, captions, contrast, structure | Image alternatives, color contrast ratio |
| Operable | Keyboard, focus, timing, navigation | Tab order, focus visibility, skip links |
| Understandable | Labels, instructions, errors, consistency | Form labels, error messages, predictable behavior |
| Robust | Valid HTML, ARIA, name/role/value | Semantic structure, accessible names |
Default: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22a, wcag22aa (WCAG 2.2 AA). Use best-practice for additional checks. See references/axe-tags-reference.md for full tag list.
When exceptions are unavoidable:
// [no] Avoid: Global rule disable
new AxeBuilder({ page }).disableRules(["color-contrast"]);
// [ok] Better: Scoped exclusion with documentation
new AxeBuilder({ page })
.exclude("#third-party-widget") // Known issue: JIRA-1234, fix by Q2
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
| Problem | Cause | Solution |
| ------------------------------------------------- | ------------------------------ | --------------------------------------- |
| Axe finds 0 violations but app fails manual audit | Automation covers ~30-40% | Add manual testing checklist |
| False positive on dynamic content | Content not fully rendered | Wait for stable state before scan |
| Color contrast fails incorrectly | Background image/gradient | Use exclude for known false positives |
| Cannot find element by role | Missing semantic HTML | Fix markup - this is a real bug |
| Focus not visible | Missing :focus styles | Add visible focus indicator CSS |
| Dialog focus not trapped | Missing focus trap logic | Implement focus trap (see snippets) |
| Skip link doesn't work | Target missing tabindex="-1" | Add tabindex to main content |
| Command | Description |
| ----------------------------------- | -------------------------------------- |
| npx playwright test --grep "a11y" | Run accessibility tests only |
| npx playwright test --headed | Run with visible browser for debugging |
| npx playwright test --debug | Step through with Inspector |
| PWDEBUG=1 npx playwright test | Debug mode with pause |
color-contrast) instead of scoped .exclude() with a documented ticket.| Document | Content |
| -------------------------------------------------------------------------------- | ------------------------------------------------ |
| Snippets: Setup & Scanning | axe-core setup, helper, and scanning patterns |
| Snippets: Keyboard, Focus, Semantic | Keyboard navigation, focus management, semantic structure |
| Snippets: Visual, Names, Checklist | Visual accessibility, accessible names, critical pages |
| WCAG 2.2 AA Checklist | Manual audit checklist by POUR principle |
| ARIA Patterns: Widgets Part 1 | Fundamentals, dialog, tabs, menu widgets |
| ARIA Patterns: Widgets Part 2 | Accordion, combobox, live regions, tooltip |
| ARIA Patterns: Mistakes & Reference | Common ARIA mistakes and roles quick reference |
| Resource | URL |
| ---------------------------- | --------------------------------------- |
| WCAG 2.2 Specification | https://www.w3.org/TR/WCAG22/ |
| WCAG Quick Reference | https://www.w3.org/WAI/WCAG22/quickref/ |
| WAI-ARIA Authoring Practices | https://www.w3.org/WAI/ARIA/apg/ |
| axe-core Rules | https://dequeuniversity.com/rules/axe/ |
AxeBuilder.analyze() returns zero critical violationswcag22a/wcag22aa included in scans (focus-not-obscured, dragging movements, target-size minimums)Take fugazi/a11y-playwright-testing from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npm, npx.
Without those the skill loads but fails at the first command.