From 9000ec6fc9839a3a2e3e15de0f059407edee9c13 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 4 May 2026 13:55:23 +0530 Subject: [PATCH] feat: remove the e2e test --- .cursor/skills/playwright-testing/SKILL.md | 99 - .../playwright-testing/accessibility.md | 1473 ------------- .../skills/playwright-testing/api-testing.md | 1617 -------------- .../assertions-and-waiting.md | 661 ------ .../skills/playwright-testing/auth-flows.md | 1030 --------- .../playwright-testing/authentication.md | 1409 ------------ .../skills/playwright-testing/browser-apis.md | 640 ------ .../playwright-testing/browser-extensions.md | 319 --- .../playwright-testing/canvas-and-webgl.md | 494 ----- .../clock-and-time-mocking.md | 427 ---- .../playwright-testing/common-pitfalls.md | 1318 ------------ .../playwright-testing/component-testing.md | 1179 ---------- .../playwright-testing/configuration.md | 729 ------- .../skills/playwright-testing/crud-testing.md | 945 -------- .../skills/playwright-testing/debugging.md | 756 ------- .../playwright-testing/drag-and-drop.md | 919 -------- .../playwright-testing/electron-testing.md | 622 ------ .../error-and-edge-cases.md | 1137 ---------- .../skills/playwright-testing/error-index.md | 1903 ----------------- .../playwright-testing/file-operations.md | 749 ------- .../file-upload-download.md | 982 --------- .../playwright-testing/fixtures-and-hooks.md | 1014 --------- .../skills/playwright-testing/flaky-tests.md | 860 -------- .../forms-and-validation.md | 1056 --------- .../i18n-and-localization.md | 622 ------ .../iframes-and-shadow-dom.md | 488 ----- .../playwright-testing/locator-strategy.md | 586 ----- .cursor/skills/playwright-testing/locators.md | 713 ------ .../mobile-and-responsive.md | 1669 --------------- .../multi-context-and-popups.md | 526 ----- .../multi-user-and-collaboration.md | 477 ----- .../playwright-testing/network-mocking.md | 1329 ------------ .cursor/skills/playwright-testing/nextjs.md | 1013 --------- .../playwright-testing/performance-testing.md | 608 ------ .cursor/skills/playwright-testing/react.md | 1082 ---------- .../playwright-testing/search-and-filter.md | 1366 ------------ .../playwright-testing/security-testing.md | 624 ------ .../service-workers-and-pwa.md | 524 ----- .../playwright-testing/test-architecture.md | 569 ----- .../test-data-management.md | 1209 ----------- .../playwright-testing/test-organization.md | 946 -------- .../third-party-integrations.md | 754 ------- .../playwright-testing/visual-regression.md | 1006 --------- .../websockets-and-realtime.md | 572 ----- .../skills/playwright-testing/when-to-mock.md | 827 ------- 45 files changed, 39848 deletions(-) delete mode 100755 .cursor/skills/playwright-testing/SKILL.md delete mode 100755 .cursor/skills/playwright-testing/accessibility.md delete mode 100755 .cursor/skills/playwright-testing/api-testing.md delete mode 100755 .cursor/skills/playwright-testing/assertions-and-waiting.md delete mode 100755 .cursor/skills/playwright-testing/auth-flows.md delete mode 100755 .cursor/skills/playwright-testing/authentication.md delete mode 100755 .cursor/skills/playwright-testing/browser-apis.md delete mode 100755 .cursor/skills/playwright-testing/browser-extensions.md delete mode 100755 .cursor/skills/playwright-testing/canvas-and-webgl.md delete mode 100755 .cursor/skills/playwright-testing/clock-and-time-mocking.md delete mode 100755 .cursor/skills/playwright-testing/common-pitfalls.md delete mode 100755 .cursor/skills/playwright-testing/component-testing.md delete mode 100755 .cursor/skills/playwright-testing/configuration.md delete mode 100755 .cursor/skills/playwright-testing/crud-testing.md delete mode 100755 .cursor/skills/playwright-testing/debugging.md delete mode 100755 .cursor/skills/playwright-testing/drag-and-drop.md delete mode 100755 .cursor/skills/playwright-testing/electron-testing.md delete mode 100755 .cursor/skills/playwright-testing/error-and-edge-cases.md delete mode 100755 .cursor/skills/playwright-testing/error-index.md delete mode 100755 .cursor/skills/playwright-testing/file-operations.md delete mode 100755 .cursor/skills/playwright-testing/file-upload-download.md delete mode 100755 .cursor/skills/playwright-testing/fixtures-and-hooks.md delete mode 100755 .cursor/skills/playwright-testing/flaky-tests.md delete mode 100755 .cursor/skills/playwright-testing/forms-and-validation.md delete mode 100755 .cursor/skills/playwright-testing/i18n-and-localization.md delete mode 100755 .cursor/skills/playwright-testing/iframes-and-shadow-dom.md delete mode 100755 .cursor/skills/playwright-testing/locator-strategy.md delete mode 100755 .cursor/skills/playwright-testing/locators.md delete mode 100755 .cursor/skills/playwright-testing/mobile-and-responsive.md delete mode 100755 .cursor/skills/playwright-testing/multi-context-and-popups.md delete mode 100755 .cursor/skills/playwright-testing/multi-user-and-collaboration.md delete mode 100755 .cursor/skills/playwright-testing/network-mocking.md delete mode 100755 .cursor/skills/playwright-testing/nextjs.md delete mode 100755 .cursor/skills/playwright-testing/performance-testing.md delete mode 100755 .cursor/skills/playwright-testing/react.md delete mode 100755 .cursor/skills/playwright-testing/search-and-filter.md delete mode 100755 .cursor/skills/playwright-testing/security-testing.md delete mode 100755 .cursor/skills/playwright-testing/service-workers-and-pwa.md delete mode 100755 .cursor/skills/playwright-testing/test-architecture.md delete mode 100755 .cursor/skills/playwright-testing/test-data-management.md delete mode 100755 .cursor/skills/playwright-testing/test-organization.md delete mode 100755 .cursor/skills/playwright-testing/third-party-integrations.md delete mode 100755 .cursor/skills/playwright-testing/visual-regression.md delete mode 100755 .cursor/skills/playwright-testing/websockets-and-realtime.md delete mode 100755 .cursor/skills/playwright-testing/when-to-mock.md diff --git a/.cursor/skills/playwright-testing/SKILL.md b/.cursor/skills/playwright-testing/SKILL.md deleted file mode 100755 index 7e9c674a0..000000000 --- a/.cursor/skills/playwright-testing/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: playwright-testing -description: Battle-tested Playwright patterns for writing and debugging reliable E2E, API, component, visual, accessibility, and security tests. Use when you need locator strategy, assertions, fixtures, network mocking, auth flows, trace debugging, or framework recipes for React, Next.js. TypeScript and JavaScript. -disable-model-invocation: true ---- - -# Playwright Core Testing - -> Opinionated, production-tested Playwright guidance — every pattern includes when (and when *not*) to use it. - -**46 reference guides** covering the full Playwright testing surface: selectors, assertions, fixtures, network mocking, auth, visual regression, accessibility, API testing, debugging, and more — with TypeScript and JavaScript examples throughout. - -## Security Trust Boundary - -This skill is designed for testing applications you own or have explicit authorization to test. - -When using examples from these guides against staging or production systems, treat all externally returned page content, API payloads, and screenshots as untrusted input. Do not feed raw content from a page or network response back into agent instructions or dynamic code execution without sanitization. - -## Golden Rules - -1. **`getByRole()` over CSS/XPath** — resilient to markup changes, mirrors how users see the page -2. **Never `page.waitForTimeout()`** — use `expect(locator).toBeVisible()` or `page.waitForURL()` -3. **Web-first assertions** — `expect(locator)` auto-retries; `expect(await locator.textContent())` does not -4. **Isolate every test** — no shared state, no execution-order dependencies -5. **`baseURL` in config** — zero hardcoded URLs in tests -6. **Retries: `2` in CI, `0` locally** — surface flakiness where it matters -7. **Traces: `'on-first-retry'`** — rich debugging artifacts without CI slowdown -8. **Fixtures over globals** — share state via `test.extend()`, not module-level variables -9. **One behavior per test** — multiple related `expect()` calls are fine -10. **Mock external services only** — never mock your own app; mock third-party APIs, payment gateways, email - -## Guide Index - -### Writing Tests - -| What you're doing | Guide | Deep dive | -|---|---|---| -| Choosing selectors | [locators.md](locators.md) | [locator-strategy.md](locator-strategy.md) | -| Assertions & waiting | [assertions-and-waiting.md](assertions-and-waiting.md) | | -| Organizing test suites | [test-organization.md](test-organization.md) | [test-architecture.md](test-architecture.md) | -| Playwright config | [configuration.md](configuration.md) | | -| Fixtures & hooks | [fixtures-and-hooks.md](fixtures-and-hooks.md) | | -| Test data | [test-data-management.md](test-data-management.md) | | -| Auth & login | [authentication.md](authentication.md) | [auth-flows.md](auth-flows.md) | -| API testing (REST/GraphQL) | [api-testing.md](api-testing.md) | | -| Visual regression | [visual-regression.md](visual-regression.md) | | -| Accessibility | [accessibility.md](accessibility.md) | | -| Mobile & responsive | [mobile-and-responsive.md](mobile-and-responsive.md) | | -| Component testing | [component-testing.md](component-testing.md) | | -| Network mocking | [network-mocking.md](network-mocking.md) | [when-to-mock.md](when-to-mock.md) | -| Forms & validation | [forms-and-validation.md](forms-and-validation.md) | | -| File uploads/downloads | [file-operations.md](file-operations.md) | [file-upload-download.md](file-upload-download.md) | -| Error & edge cases | [error-and-edge-cases.md](error-and-edge-cases.md) | | -| CRUD flows | [crud-testing.md](crud-testing.md) | | -| Drag and drop | [drag-and-drop.md](drag-and-drop.md) | | -| Search & filter UI | [search-and-filter.md](search-and-filter.md) | | - -### Debugging & Fixing - -| Problem | Guide | -|---|---| -| General debugging workflow | [debugging.md](debugging.md) | -| Specific error message | [error-index.md](error-index.md) | -| Flaky / intermittent tests | [flaky-tests.md](flaky-tests.md) | -| Common beginner mistakes | [common-pitfalls.md](common-pitfalls.md) | - -### Framework Recipes - -| Framework | Guide | -|---|---| -| Next.js (App Router + Pages Router) | [nextjs.md](nextjs.md) | -| React (CRA, Vite) | [react.md](react.md) | - -### Specialized Topics - -| Topic | Guide | -|---|---| -| Multi-user & collaboration | [multi-user-and-collaboration.md](multi-user-and-collaboration.md) | -| WebSockets & real-time | [websockets-and-realtime.md](websockets-and-realtime.md) | -| Browser APIs (geo, clipboard, permissions) | [browser-apis.md](browser-apis.md) | -| iframes & Shadow DOM | [iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) | -| Canvas & WebGL | [canvas-and-webgl.md](canvas-and-webgl.md) | -| Service workers & PWA | [service-workers-and-pwa.md](service-workers-and-pwa.md) | -| Electron apps | [electron-testing.md](electron-testing.md) | -| Browser extensions | [browser-extensions.md](browser-extensions.md) | -| Security testing | [security-testing.md](security-testing.md) | -| Performance & benchmarks | [performance-testing.md](performance-testing.md) | -| i18n & localization | [i18n-and-localization.md](i18n-and-localization.md) | -| Multi-tab & popups | [multi-context-and-popups.md](multi-context-and-popups.md) | -| Clock & time mocking | [clock-and-time-mocking.md](clock-and-time-mocking.md) | -| Third-party integrations | [third-party-integrations.md](third-party-integrations.md) | - -### Architecture Decisions - -| Question | Guide | -|---|---| -| Which locator strategy? | [locator-strategy.md](locator-strategy.md) | -| E2E vs component vs API? | [test-architecture.md](test-architecture.md) | -| Mock vs real services? | [when-to-mock.md](when-to-mock.md) | diff --git a/.cursor/skills/playwright-testing/accessibility.md b/.cursor/skills/playwright-testing/accessibility.md deleted file mode 100755 index b472d9257..000000000 --- a/.cursor/skills/playwright-testing/accessibility.md +++ /dev/null @@ -1,1473 +0,0 @@ -# Accessibility Testing - -> **When to use**: Every project. Accessibility is not a feature — it is a quality baseline. Integrate automated checks (axe-core) into every test suite and supplement with manual keyboard/screen-reader verification for critical flows. -> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md) - -## Quick Reference - -```typescript -// Install: npm install -D @axe-core/playwright -import AxeBuilder from '@axe-core/playwright'; - -// Full page scan -const results = await new AxeBuilder({ page }).analyze(); -expect(results.violations).toEqual([]); - -// Scoped scan — only the main content area -const results = await new AxeBuilder({ page }).include('#main-content').analyze(); - -// WCAG AA only -const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); - -// Exclude known issues during migration -const results = await new AxeBuilder({ page }).disableRules(['color-contrast']).analyze(); - -// Playwright 1.59+: capture the accessibility tree for the whole page -const pageTree = await page.ariaSnapshot(); - -// Or scope it to one region -const dialogTree = await page.getByRole('dialog', { name: 'Checkout' }).ariaSnapshot(); -``` - -## Patterns - -### ARIA Snapshots For Structure Checks - -**Use when**: You want to verify the accessibility tree shape of a page, region, dialog, or widget in addition to running axe. -**Avoid when**: You only need rule-based WCAG checks. Start with axe for broad coverage, then use ARIA snapshots for high-value structure assertions. - -Playwright 1.59 adds `page.ariaSnapshot()` as a shortcut for capturing the page-level accessibility tree, and expands `locator.ariaSnapshot()` with more control over depth and snapshot mode. This is useful for menus, dialogs, composite widgets, and other components where semantic structure matters as much as raw DOM shape. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; - -test('checkout dialog exposes the expected accessibility structure', async ({ page }) => { - await page.goto('/checkout'); - await page.getByRole('button', { name: 'Open checkout' }).click(); - - const dialogTree = await page - .getByRole('dialog', { name: 'Checkout' }) - .ariaSnapshot(); - - expect(dialogTree).toContain('heading "Checkout"'); - expect(dialogTree).toContain('button "Apply coupon"'); -}); -``` - -**Snapshot options** - -When the full accessibility tree is too noisy, use the newer options to limit the result to the level of detail you actually care about. - -```typescript -const menuTree = await page.getByRole('menu', { name: 'Account' }).ariaSnapshot({ - depth: 2, -}); - -const summaryTree = await page.getByRole('dialog', { name: 'Checkout' }).ariaSnapshot({ - mode: 'summary', -}); -``` - -Use smaller snapshots for stable assertions. Deep full-tree snapshots are powerful, but they can become brittle if the component structure changes often. - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); - -test('checkout dialog exposes the expected accessibility structure', async ({ page }) => { - await page.goto('/checkout'); - await page.getByRole('button', { name: 'Open checkout' }).click(); - - const dialogTree = await page - .getByRole('dialog', { name: 'Checkout' }) - .ariaSnapshot(); - - expect(dialogTree).toContain('heading "Checkout"'); - expect(dialogTree).toContain('button "Apply coupon"'); -}); -``` - -### axe-core/playwright Integration - -**Use when**: You want automated WCAG violation detection on any page or component. This is your first line of defense and should run in every test suite. -**Avoid when**: You need to verify subjective UX quality (reading order, cognitive load, plain language). axe-core catches structural violations, not usability problems. - -axe-core detects roughly 30-40% of WCAG issues automatically. That 30-40% includes the most common and egregious violations: missing alt text, broken label associations, invalid ARIA, and contrast failures. Catching these automatically frees you to spend manual effort on the harder problems. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('accessibility', () => { - test('home page has no accessibility violations', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }).analyze(); - - expect(results.violations).toEqual([]); - }); - - test('dashboard has no accessibility violations after login', async ({ page }) => { - await page.goto('/login'); - await page.getByLabel('Email').fill('user@example.com'); - await page.getByLabel('Password').fill('password123'); - await page.getByRole('button', { name: 'Sign in' }).click(); - await page.waitForURL('/dashboard'); - - // Scan after the page is fully interactive - const results = await new AxeBuilder({ page }).analyze(); - - expect(results.violations).toEqual([]); - }); - - test('report violations with helpful details on failure', async ({ page }) => { - await page.goto('/products'); - - const results = await new AxeBuilder({ page }).analyze(); - - // Format violations for readable test output - const violationSummary = results.violations.map((v) => ({ - rule: v.id, - impact: v.impact, - description: v.description, - nodes: v.nodes.length, - help: v.helpUrl, - })); - - expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -test.describe('accessibility', () => { - test('home page has no accessibility violations', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }).analyze(); - - expect(results.violations).toEqual([]); - }); - - test('report violations with helpful details on failure', async ({ page }) => { - await page.goto('/products'); - - const results = await new AxeBuilder({ page }).analyze(); - - const violationSummary = results.violations.map((v) => ({ - rule: v.id, - impact: v.impact, - description: v.description, - nodes: v.nodes.length, - help: v.helpUrl, - })); - - expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]); - }); -}); -``` - -### Scanning Specific Regions - -**Use when**: You want to focus axe-core on a specific component (new feature, redesigned section) or exclude areas you do not control (third-party widgets, ads, embedded iframes). -**Avoid when**: You want a full-page baseline. Scan everything first, then narrow down. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('scoped accessibility scans', () => { - test('scan only the checkout form', async ({ page }) => { - await page.goto('/checkout'); - - const results = await new AxeBuilder({ page }) - .include('#checkout-form') - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('scan page excluding third-party chat widget', async ({ page }) => { - await page.goto('/support'); - - const results = await new AxeBuilder({ page }) - .exclude('#intercom-widget') - .exclude('.third-party-ads') - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('scan multiple specific regions', async ({ page }) => { - await page.goto('/dashboard'); - - // Include multiple areas — each is scanned independently - const results = await new AxeBuilder({ page }) - .include('#navigation') - .include('#main-content') - .include('#footer') - .exclude('.ad-banner') - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('scan a modal after it opens', async ({ page }) => { - await page.goto('/settings'); - await page.getByRole('button', { name: 'Delete account' }).click(); - - // Wait for the modal to be fully rendered - await expect(page.getByRole('dialog', { name: 'Confirm deletion' })).toBeVisible(); - - const results = await new AxeBuilder({ page }) - .include('[role="dialog"]') - .analyze(); - - expect(results.violations).toEqual([]); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -test.describe('scoped accessibility scans', () => { - test('scan only the checkout form', async ({ page }) => { - await page.goto('/checkout'); - - const results = await new AxeBuilder({ page }) - .include('#checkout-form') - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('scan page excluding third-party chat widget', async ({ page }) => { - await page.goto('/support'); - - const results = await new AxeBuilder({ page }) - .exclude('#intercom-widget') - .exclude('.third-party-ads') - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('scan a modal after it opens', async ({ page }) => { - await page.goto('/settings'); - await page.getByRole('button', { name: 'Delete account' }).click(); - await expect(page.getByRole('dialog', { name: 'Confirm deletion' })).toBeVisible(); - - const results = await new AxeBuilder({ page }) - .include('[role="dialog"]') - .analyze(); - - expect(results.violations).toEqual([]); - }); -}); -``` - -### WCAG Compliance Levels - -**Use when**: Your project targets a specific WCAG compliance level (most target AA). Use tags to limit axe-core to the rules that matter for your compliance requirement. -**Avoid when**: You want the broadest possible scan. Omitting `withTags()` runs all rules, including best practices beyond WCAG. - -Tag reference: -- `wcag2a` — WCAG 2.0 Level A (minimum) -- `wcag2aa` — WCAG 2.0 Level AA (standard target for most organizations) -- `wcag2aaa` — WCAG 2.0 Level AAA (strict; rarely required) -- `wcag21a`, `wcag21aa`, `wcag21aaa` — WCAG 2.1 additions -- `wcag22aa` — WCAG 2.2 additions -- `best-practice` — not WCAG, but recommended patterns - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('WCAG compliance levels', () => { - test('meets WCAG 2.1 AA (standard compliance target)', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('meets WCAG 2.2 AA (latest standard)', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('meets WCAG AAA (strict — use for government or healthcare)', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('best practices beyond WCAG', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['best-practice']) - .analyze(); - - // Use soft assertion — best practices are advisory, not blocking - expect.soft(results.violations).toEqual([]); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -test.describe('WCAG compliance levels', () => { - test('meets WCAG 2.1 AA (standard compliance target)', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('meets WCAG 2.2 AA (latest standard)', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) - .analyze(); - - expect(results.violations).toEqual([]); - }); -}); -``` - -### Disabling Specific Rules - -**Use when**: Migrating a legacy app to accessibility compliance incrementally. You have known violations documented in a tracking system and want the test suite to catch new regressions without failing on existing known issues. -**Avoid when**: Hiding violations you do not intend to fix. Every disabled rule should have a tracking ticket. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -// Centralize known exceptions — makes them visible and trackable -const KNOWN_ISSUES = { - // JIRA-1234: Legacy header component, scheduled for redesign Q2 - rules: ['color-contrast'], - // JIRA-1235: Third-party date picker has no label association - selectors: ['#legacy-datepicker'], -}; - -test.describe('accessibility with known exceptions', () => { - test('no new violations (excluding tracked known issues)', async ({ page }) => { - await page.goto('/dashboard'); - - const results = await new AxeBuilder({ page }) - .disableRules(KNOWN_ISSUES.rules) - .exclude(KNOWN_ISSUES.selectors[0]) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('verify known issues still exist (remove when fixed)', async ({ page }) => { - await page.goto('/dashboard'); - - // Scan ONLY for the known issues to confirm they still exist - // When this test fails (violations disappear), remove the exception - const results = await new AxeBuilder({ page }) - .withRules(KNOWN_ISSUES.rules) - .analyze(); - - if (results.violations.length === 0) { - console.warn( - 'Known accessibility issues appear to be fixed. ' + - 'Remove exceptions from KNOWN_ISSUES and close tracking tickets.' - ); - } - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -const KNOWN_ISSUES = { - rules: ['color-contrast'], - selectors: ['#legacy-datepicker'], -}; - -test.describe('accessibility with known exceptions', () => { - test('no new violations (excluding tracked known issues)', async ({ page }) => { - await page.goto('/dashboard'); - - const results = await new AxeBuilder({ page }) - .disableRules(KNOWN_ISSUES.rules) - .exclude(KNOWN_ISSUES.selectors[0]) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('verify known issues still exist (remove when fixed)', async ({ page }) => { - await page.goto('/dashboard'); - - const results = await new AxeBuilder({ page }) - .withRules(KNOWN_ISSUES.rules) - .analyze(); - - if (results.violations.length === 0) { - console.warn( - 'Known accessibility issues appear to be fixed. ' + - 'Remove exceptions from KNOWN_ISSUES and close tracking tickets.' - ); - } - }); -}); -``` - -### Keyboard Navigation Testing - -**Use when**: Verifying that all interactive elements are reachable and operable via keyboard alone. This is critical for motor-impaired users and power users who navigate without a mouse. -**Avoid when**: Never skip this. Automated tools cannot fully verify keyboard navigation — this requires behavioral tests. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; - -test.describe('keyboard navigation', () => { - test('tab order follows logical reading order', async ({ page }) => { - await page.goto('/login'); - - // Tab through interactive elements and verify focus order - await page.keyboard.press('Tab'); - await expect(page.getByLabel('Email')).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByLabel('Password')).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByRole('link', { name: 'Forgot password?' })).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByRole('button', { name: 'Sign in' })).toBeFocused(); - - // Verify Enter activates the focused button - await page.getByLabel('Email').fill('user@example.com'); - await page.getByLabel('Password').fill('password123'); - await page.getByRole('button', { name: 'Sign in' }).focus(); - await page.keyboard.press('Enter'); - await page.waitForURL('/dashboard'); - }); - - test('skip navigation link moves focus to main content', async ({ page }) => { - await page.goto('/'); - - // First Tab should land on the skip link (visually hidden until focused) - await page.keyboard.press('Tab'); - const skipLink = page.getByRole('link', { name: 'Skip to main content' }); - await expect(skipLink).toBeFocused(); - - // Activating the skip link moves focus past the nav - await page.keyboard.press('Enter'); - await expect(page.locator('#main-content')).toBeFocused(); - }); - - test('dropdown menu operates with keyboard', async ({ page }) => { - await page.goto('/dashboard'); - - const menuButton = page.getByRole('button', { name: 'User menu' }); - await menuButton.focus(); - - // Open menu with Enter or Space - await page.keyboard.press('Enter'); - const menu = page.getByRole('menu'); - await expect(menu).toBeVisible(); - - // Arrow keys navigate menu items - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeFocused(); - - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeFocused(); - - // Escape closes the menu and returns focus to the trigger - await page.keyboard.press('Escape'); - await expect(menu).not.toBeVisible(); - await expect(menuButton).toBeFocused(); - }); - - test('keyboard shortcuts work correctly', async ({ page }) => { - await page.goto('/editor'); - - // Ctrl+S / Cmd+S triggers save - const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; - const saveResponse = page.waitForResponse('**/api/save'); - await page.keyboard.press(`${modifier}+s`); - await saveResponse; - - await expect(page.getByText('Saved')).toBeVisible(); - }); - - test('no keyboard traps in form navigation', async ({ page }) => { - await page.goto('/complex-form'); - - // Tab through every field — focus should never get stuck - const interactiveElements = page.locator( - 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const count = await interactiveElements.count(); - - for (let i = 0; i < count; i++) { - await page.keyboard.press('Tab'); - // Verify something is focused (focus did not get trapped or lost) - const focused = page.locator(':focus'); - await expect(focused).toBeAttached(); - } - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); - -test.describe('keyboard navigation', () => { - test('tab order follows logical reading order', async ({ page }) => { - await page.goto('/login'); - - await page.keyboard.press('Tab'); - await expect(page.getByLabel('Email')).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByLabel('Password')).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByRole('link', { name: 'Forgot password?' })).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(page.getByRole('button', { name: 'Sign in' })).toBeFocused(); - }); - - test('dropdown menu operates with keyboard', async ({ page }) => { - await page.goto('/dashboard'); - - const menuButton = page.getByRole('button', { name: 'User menu' }); - await menuButton.focus(); - - await page.keyboard.press('Enter'); - const menu = page.getByRole('menu'); - await expect(menu).toBeVisible(); - - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeFocused(); - - await page.keyboard.press('Escape'); - await expect(menu).not.toBeVisible(); - await expect(menuButton).toBeFocused(); - }); - - test('no keyboard traps in form navigation', async ({ page }) => { - await page.goto('/complex-form'); - - const interactiveElements = page.locator( - 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const count = await interactiveElements.count(); - - for (let i = 0; i < count; i++) { - await page.keyboard.press('Tab'); - const focused = page.locator(':focus'); - await expect(focused).toBeAttached(); - } - }); -}); -``` - -### Screen Reader Testing Patterns - -**Use when**: Verifying that ARIA attributes, live regions, and roles produce the correct accessible experience. You cannot run a real screen reader in CI, but you can verify the semantic structure that screen readers depend on. -**Avoid when**: You want to test actual screen reader output (use manual testing with NVDA/VoiceOver for that). - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; - -test.describe('screen reader semantics', () => { - test('ARIA labels provide meaningful context', async ({ page }) => { - await page.goto('/dashboard'); - - // Navigation landmarks must have distinct labels - const mainNav = page.getByRole('navigation', { name: 'Main' }); - const footerNav = page.getByRole('navigation', { name: 'Footer' }); - await expect(mainNav).toBeVisible(); - await expect(footerNav).toBeVisible(); - - // Regions should have accessible names - const mainRegion = page.getByRole('main'); - await expect(mainRegion).toBeAttached(); - - // Buttons with icons must have accessible names - const closeButton = page.getByRole('button', { name: 'Close' }); - await expect(closeButton).toBeAttached(); - - // Images must have alt text (getByRole('img') only matches with accessible name) - const logo = page.getByRole('img', { name: 'Company logo' }); - await expect(logo).toBeVisible(); - }); - - test('live regions announce dynamic content changes', async ({ page }) => { - await page.goto('/notifications'); - - // Verify the live region exists before triggering content - const statusRegion = page.locator('[aria-live="polite"]'); - await expect(statusRegion).toBeAttached(); - - // Trigger an action that updates the live region - await page.getByRole('button', { name: 'Save' }).click(); - - // Verify the live region received the update - await expect(statusRegion).toHaveText('Changes saved successfully'); - }); - - test('alert live region announces errors immediately', async ({ page }) => { - await page.goto('/checkout'); - - // aria-live="assertive" or role="alert" interrupts the screen reader - await page.getByRole('button', { name: 'Place order' }).click(); - - const alert = page.getByRole('alert'); - await expect(alert).toBeVisible(); - await expect(alert).toHaveText('Payment method is required'); - }); - - test('expandable sections announce their state', async ({ page }) => { - await page.goto('/faq'); - - const faqButton = page.getByRole('button', { name: 'How do I reset my password?' }); - - // aria-expanded should reflect the current state - await expect(faqButton).toHaveAttribute('aria-expanded', 'false'); - - await faqButton.click(); - await expect(faqButton).toHaveAttribute('aria-expanded', 'true'); - - // The controlled panel should be visible - const panel = page.locator(`#${await faqButton.getAttribute('aria-controls')}`); - await expect(panel).toBeVisible(); - }); - - test('page headings form a logical hierarchy', async ({ page }) => { - await page.goto('/about'); - - // There should be exactly one h1 - await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); - - // Heading levels should not skip (h1 -> h3 without h2 is a violation) - const headings = page.getByRole('heading'); - const count = await headings.count(); - let previousLevel = 0; - - for (let i = 0; i < count; i++) { - const heading = headings.nth(i); - const tagName = await heading.evaluate((el) => el.tagName.toLowerCase()); - const level = parseInt(tagName.replace('h', ''), 10); - - // Level can go up by 1 or drop to any lower level, but never skip forward - if (level > previousLevel + 1 && previousLevel !== 0) { - throw new Error( - `Heading hierarchy skipped from h${previousLevel} to h${level}: "${await heading.textContent()}"` - ); - } - previousLevel = level; - } - }); - - test('table has proper headers and caption', async ({ page }) => { - await page.goto('/reports'); - - const table = page.getByRole('table', { name: 'Monthly revenue' }); - await expect(table).toBeVisible(); - - // Column headers - const columnHeaders = table.getByRole('columnheader'); - await expect(columnHeaders).toHaveCount(4); - await expect(columnHeaders.first()).toHaveText('Month'); - - // Row headers (if applicable) - const rowHeaders = table.getByRole('rowheader'); - await expect(rowHeaders.first()).toHaveText('January'); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); - -test.describe('screen reader semantics', () => { - test('ARIA labels provide meaningful context', async ({ page }) => { - await page.goto('/dashboard'); - - const mainNav = page.getByRole('navigation', { name: 'Main' }); - const footerNav = page.getByRole('navigation', { name: 'Footer' }); - await expect(mainNav).toBeVisible(); - await expect(footerNav).toBeVisible(); - - const closeButton = page.getByRole('button', { name: 'Close' }); - await expect(closeButton).toBeAttached(); - - const logo = page.getByRole('img', { name: 'Company logo' }); - await expect(logo).toBeVisible(); - }); - - test('live regions announce dynamic content changes', async ({ page }) => { - await page.goto('/notifications'); - - const statusRegion = page.locator('[aria-live="polite"]'); - await expect(statusRegion).toBeAttached(); - - await page.getByRole('button', { name: 'Save' }).click(); - await expect(statusRegion).toHaveText('Changes saved successfully'); - }); - - test('expandable sections announce their state', async ({ page }) => { - await page.goto('/faq'); - - const faqButton = page.getByRole('button', { name: 'How do I reset my password?' }); - await expect(faqButton).toHaveAttribute('aria-expanded', 'false'); - - await faqButton.click(); - await expect(faqButton).toHaveAttribute('aria-expanded', 'true'); - }); -}); -``` - -### Color Contrast Verification - -**Use when**: Ensuring text and UI components meet WCAG contrast ratio requirements. axe-core checks contrast automatically, but you may need explicit checks for dynamic themes, dark mode, or brand color changes. -**Avoid when**: axe-core's built-in contrast rule covers your use case. Only add explicit checks for dynamic color changes axe cannot observe in a single scan. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('color contrast', () => { - test('light theme meets contrast requirements', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withRules(['color-contrast']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('dark theme meets contrast requirements', async ({ page }) => { - await page.goto('/'); - - // Activate dark mode - await page.getByRole('button', { name: 'Toggle dark mode' }).click(); - - // Wait for theme transition to complete - await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); - - const results = await new AxeBuilder({ page }) - .withRules(['color-contrast']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('high contrast mode meets AAA contrast requirements', async ({ page }) => { - await page.goto('/settings/display'); - await page.getByRole('checkbox', { name: 'High contrast' }).check(); - - // AAA requires 7:1 for normal text, 4.5:1 for large text - const results = await new AxeBuilder({ page }) - .withTags(['wcag2aaa']) - .withRules(['color-contrast']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('focus indicators are visible', async ({ page }) => { - await page.goto('/'); - - // Tab to an element and verify focus outline has sufficient contrast - await page.keyboard.press('Tab'); - const focusedElement = page.locator(':focus'); - - // Verify the outline is not transparent or zero-width - const outline = await focusedElement.evaluate((el) => { - const styles = window.getComputedStyle(el); - return { - outlineStyle: styles.outlineStyle, - outlineWidth: styles.outlineWidth, - outlineColor: styles.outlineColor, - boxShadow: styles.boxShadow, - }; - }); - - // Focus must be visible — either outline or box-shadow - const hasVisibleFocus = - (outline.outlineStyle !== 'none' && outline.outlineWidth !== '0px') || - outline.boxShadow !== 'none'; - - expect(hasVisibleFocus, 'Focused element must have a visible focus indicator').toBe(true); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -test.describe('color contrast', () => { - test('light theme meets contrast requirements', async ({ page }) => { - await page.goto('/'); - - const results = await new AxeBuilder({ page }) - .withRules(['color-contrast']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('dark theme meets contrast requirements', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Toggle dark mode' }).click(); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); - - const results = await new AxeBuilder({ page }) - .withRules(['color-contrast']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('focus indicators are visible', async ({ page }) => { - await page.goto('/'); - await page.keyboard.press('Tab'); - const focusedElement = page.locator(':focus'); - - const outline = await focusedElement.evaluate((el) => { - const styles = window.getComputedStyle(el); - return { - outlineStyle: styles.outlineStyle, - outlineWidth: styles.outlineWidth, - boxShadow: styles.boxShadow, - }; - }); - - const hasVisibleFocus = - (outline.outlineStyle !== 'none' && outline.outlineWidth !== '0px') || - outline.boxShadow !== 'none'; - - expect(hasVisibleFocus, 'Focused element must have a visible focus indicator').toBe(true); - }); -}); -``` - -### Focus Trap Testing - -**Use when**: Testing modals, dialogs, dropdown menus, slide-over panels, and any overlay that must trap focus within itself to prevent users from accidentally interacting with background content. -**Avoid when**: The component does not overlay content (inline expandable sections do not need focus traps). - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; - -test.describe('focus trap', () => { - test('modal traps focus within itself', async ({ page }) => { - await page.goto('/settings'); - - // Open the modal - await page.getByRole('button', { name: 'Delete account' }).click(); - const dialog = page.getByRole('dialog', { name: 'Confirm deletion' }); - await expect(dialog).toBeVisible(); - - // Focus should move into the dialog automatically - const firstFocusable = dialog.getByRole('button', { name: 'Cancel' }); - await expect(firstFocusable).toBeFocused(); - - // Tab should cycle within the dialog - await page.keyboard.press('Tab'); - await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused(); - - // Tab again wraps back to the first focusable element - await page.keyboard.press('Tab'); - await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); - - // Shift+Tab wraps to the last focusable element - await page.keyboard.press('Shift+Tab'); - await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused(); - - // Escape closes the dialog - await page.keyboard.press('Escape'); - await expect(dialog).not.toBeVisible(); - - // Focus returns to the trigger element - await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused(); - }); - - test('dropdown menu traps focus and returns it on close', async ({ page }) => { - await page.goto('/dashboard'); - - const trigger = page.getByRole('button', { name: 'Actions' }); - await trigger.click(); - - const menu = page.getByRole('menu'); - await expect(menu).toBeVisible(); - - // First menu item receives focus - await expect(page.getByRole('menuitem').first()).toBeFocused(); - - // ArrowDown moves through items - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('menuitem').nth(1)).toBeFocused(); - - // Escape closes and returns focus - await page.keyboard.press('Escape'); - await expect(menu).not.toBeVisible(); - await expect(trigger).toBeFocused(); - }); - - test('background content is inert when modal is open', async ({ page }) => { - await page.goto('/settings'); - await page.getByRole('button', { name: 'Delete account' }).click(); - - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Background content should have aria-hidden="true" or be inert - const mainContent = page.locator('main'); - const isHidden = await mainContent.evaluate((el) => { - return el.getAttribute('aria-hidden') === 'true' || el.hasAttribute('inert'); - }); - - expect(isHidden, 'Background content must be hidden from assistive technology').toBe(true); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); - -test.describe('focus trap', () => { - test('modal traps focus within itself', async ({ page }) => { - await page.goto('/settings'); - await page.getByRole('button', { name: 'Delete account' }).click(); - - const dialog = page.getByRole('dialog', { name: 'Confirm deletion' }); - await expect(dialog).toBeVisible(); - - const firstFocusable = dialog.getByRole('button', { name: 'Cancel' }); - await expect(firstFocusable).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); - - await page.keyboard.press('Escape'); - await expect(dialog).not.toBeVisible(); - await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused(); - }); - - test('background content is inert when modal is open', async ({ page }) => { - await page.goto('/settings'); - await page.getByRole('button', { name: 'Delete account' }).click(); - await expect(page.getByRole('dialog')).toBeVisible(); - - const mainContent = page.locator('main'); - const isHidden = await mainContent.evaluate((el) => { - return el.getAttribute('aria-hidden') === 'true' || el.hasAttribute('inert'); - }); - - expect(isHidden, 'Background content must be hidden from assistive technology').toBe(true); - }); -}); -``` - -### Accessible Forms - -**Use when**: Testing that forms are usable by assistive technology. Every form field must have an associated label, error messages must be programmatically linked, and required fields must be announced. -**Avoid when**: Never skip this for any form. - -**TypeScript** -```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('accessible forms', () => { - test('all form fields have associated labels', async ({ page }) => { - await page.goto('/register'); - - // Every input should be reachable via getByLabel (proves label association) - await expect(page.getByLabel('First name')).toBeVisible(); - await expect(page.getByLabel('Last name')).toBeVisible(); - await expect(page.getByLabel('Email')).toBeVisible(); - await expect(page.getByLabel('Password')).toBeVisible(); - - // Run axe to catch any we missed - const results = await new AxeBuilder({ page }) - .include('form') - .withRules(['label', 'label-title-only']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('required fields are announced to screen readers', async ({ page }) => { - await page.goto('/register'); - - // Required fields must have aria-required="true" or the required attribute - const emailField = page.getByLabel('Email'); - const hasRequired = await emailField.evaluate((el) => { - return el.hasAttribute('required') || el.getAttribute('aria-required') === 'true'; - }); - - expect(hasRequired, 'Email field must be marked as required').toBe(true); - }); - - test('error messages are linked to their fields via aria-describedby', async ({ page }) => { - await page.goto('/register'); - - // Submit empty form to trigger validation - await page.getByRole('button', { name: 'Create account' }).click(); - - // The error message should be visible - const errorMessage = page.getByText('Email is required'); - await expect(errorMessage).toBeVisible(); - - // The error must be linked to the field via aria-describedby - const emailField = page.getByLabel('Email'); - const describedBy = await emailField.getAttribute('aria-describedby'); - expect(describedBy).toBeTruthy(); - - // The id of the error message matches the aria-describedby value - const errorId = await errorMessage.getAttribute('id'); - expect(describedBy).toContain(errorId); - - // The field should also indicate invalid state - await expect(emailField).toHaveAttribute('aria-invalid', 'true'); - }); - - test('form error summary is announced and links to fields', async ({ page }) => { - await page.goto('/register'); - await page.getByRole('button', { name: 'Create account' }).click(); - - // Error summary should appear with role="alert" for immediate announcement - const errorSummary = page.getByRole('alert'); - await expect(errorSummary).toBeVisible(); - await expect(errorSummary).toContainText('Please fix the following errors'); - - // Error summary links should move focus to the corresponding field - await errorSummary.getByRole('link', { name: 'Email is required' }).click(); - await expect(page.getByLabel('Email')).toBeFocused(); - }); - - test('autocomplete attributes are set for common fields', async ({ page }) => { - await page.goto('/checkout'); - - // Autocomplete helps password managers and assistive tech fill forms - await expect(page.getByLabel('Full name')).toHaveAttribute('autocomplete', 'name'); - await expect(page.getByLabel('Email')).toHaveAttribute('autocomplete', 'email'); - await expect(page.getByLabel('Street address')).toHaveAttribute('autocomplete', 'street-address'); - await expect(page.getByLabel('Postal code')).toHaveAttribute('autocomplete', 'postal-code'); - }); - - test('fieldsets group related fields with legends', async ({ page }) => { - await page.goto('/checkout'); - - // Related fields should be grouped in fieldsets with legends - const shippingGroup = page.getByRole('group', { name: 'Shipping address' }); - await expect(shippingGroup).toBeVisible(); - await expect(shippingGroup.getByLabel('Street address')).toBeVisible(); - - const billingGroup = page.getByRole('group', { name: 'Billing address' }); - await expect(billingGroup).toBeVisible(); - }); -}); -``` - -**JavaScript** -```javascript -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -test.describe('accessible forms', () => { - test('all form fields have associated labels', async ({ page }) => { - await page.goto('/register'); - - await expect(page.getByLabel('First name')).toBeVisible(); - await expect(page.getByLabel('Last name')).toBeVisible(); - await expect(page.getByLabel('Email')).toBeVisible(); - await expect(page.getByLabel('Password')).toBeVisible(); - - const results = await new AxeBuilder({ page }) - .include('form') - .withRules(['label', 'label-title-only']) - .analyze(); - - expect(results.violations).toEqual([]); - }); - - test('error messages are linked to their fields via aria-describedby', async ({ page }) => { - await page.goto('/register'); - await page.getByRole('button', { name: 'Create account' }).click(); - - const errorMessage = page.getByText('Email is required'); - await expect(errorMessage).toBeVisible(); - - const emailField = page.getByLabel('Email'); - const describedBy = await emailField.getAttribute('aria-describedby'); - expect(describedBy).toBeTruthy(); - - const errorId = await errorMessage.getAttribute('id'); - expect(describedBy).toContain(errorId); - - await expect(emailField).toHaveAttribute('aria-invalid', 'true'); - }); - - test('autocomplete attributes are set for common fields', async ({ page }) => { - await page.goto('/checkout'); - - await expect(page.getByLabel('Full name')).toHaveAttribute('autocomplete', 'name'); - await expect(page.getByLabel('Email')).toHaveAttribute('autocomplete', 'email'); - await expect(page.getByLabel('Street address')).toHaveAttribute('autocomplete', 'street-address'); - }); -}); -``` - -### Accessibility in CI - -**Use when**: You want accessibility violations to fail builds, preventing regressions from reaching production. Every team should gate their CI pipeline on accessibility. -**Avoid when**: Never. If you only run accessibility checks locally, they will be skipped. - -**TypeScript** -```typescript -// playwright.config.ts — dedicated accessibility project -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - projects: [ - { - name: 'accessibility', - testMatch: '**/*.a11y.spec.ts', - use: { - browserName: 'chromium', // axe-core works best with Chromium - }, - }, - { - name: 'e2e-chromium', - testMatch: '**/*.spec.ts', - testIgnore: '**/*.a11y.spec.ts', - use: { browserName: 'chromium' }, - }, - ], -}); -``` - -```typescript -// tests/pages.a11y.spec.ts — scan all critical pages -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -const PAGES_TO_SCAN = [ - { name: 'Home', path: '/' }, - { name: 'Login', path: '/login' }, - { name: 'Register', path: '/register' }, - { name: 'Dashboard', path: '/dashboard' }, - { name: 'Products', path: '/products' }, - { name: 'Checkout', path: '/checkout' }, - { name: 'Contact', path: '/contact' }, -]; - -for (const { name, path } of PAGES_TO_SCAN) { - test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => { - await page.goto(path); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - - // Attach violation details to the test report - await test.info().attach('accessibility-scan-results', { - body: JSON.stringify(results.violations, null, 2), - contentType: 'application/json', - }); - - expect(results.violations).toEqual([]); - }); -} -``` - -```typescript -// tests/helpers/a11y-fixture.ts — reusable axe-core fixture -import { test as base, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -type A11yFixtures = { - makeAxeBuilder: () => AxeBuilder; -}; - -export const test = base.extend({ - makeAxeBuilder: async ({ page }, use) => { - const makeAxeBuilder = () => - new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']); - await use(makeAxeBuilder); - }, -}); - -export { expect }; -``` - -```typescript -// tests/dashboard.a11y.spec.ts — using the fixture -import { test, expect } from './helpers/a11y-fixture'; - -test('dashboard has no violations after data loads', async ({ page, makeAxeBuilder }) => { - await page.goto('/dashboard'); - await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); - - const results = await makeAxeBuilder().analyze(); - - await test.info().attach('a11y-results', { - body: JSON.stringify(results.violations, null, 2), - contentType: 'application/json', - }); - - expect(results.violations).toEqual([]); -}); -``` - -**JavaScript** -```javascript -// playwright.config.js -const { defineConfig } = require('@playwright/test'); - -module.exports = defineConfig({ - projects: [ - { - name: 'accessibility', - testMatch: '**/*.a11y.spec.js', - use: { browserName: 'chromium' }, - }, - { - name: 'e2e-chromium', - testMatch: '**/*.spec.js', - testIgnore: '**/*.a11y.spec.js', - use: { browserName: 'chromium' }, - }, - ], -}); -``` - -```javascript -// tests/pages.a11y.spec.js -const { test, expect } = require('@playwright/test'); -const AxeBuilder = require('@axe-core/playwright').default; - -const PAGES_TO_SCAN = [ - { name: 'Home', path: '/' }, - { name: 'Login', path: '/login' }, - { name: 'Dashboard', path: '/dashboard' }, - { name: 'Products', path: '/products' }, -]; - -for (const { name, path } of PAGES_TO_SCAN) { - test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => { - await page.goto(path); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - - await test.info().attach('accessibility-scan-results', { - body: JSON.stringify(results.violations, null, 2), - contentType: 'application/json', - }); - - expect(results.violations).toEqual([]); - }); -} -``` - -**GitHub Actions integration:** - -```yaml -# .github/workflows/accessibility.yml -name: Accessibility Tests -on: [push, pull_request] - -jobs: - accessibility: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npx playwright install --with-deps chromium - - - name: Run accessibility tests - run: npx playwright test --project=accessibility - - - name: Upload accessibility report - if: always() - uses: actions/upload-artifact@v4 - with: - name: accessibility-report - path: playwright-report/ - retention-days: 30 -``` - -## Decision Guide - -| What to Check | Automated (axe-core) | Manual (Keyboard/Screen Reader) | Why | -|---|---|---|---| -| Missing alt text | Yes | No | axe-core detects this reliably | -| Color contrast ratios | Yes | No | Computed automatically from CSS | -| Missing form labels | Yes | No | Detects missing `