specweave 1.0.239 → 1.0.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +31 -30
- package/README.md +1 -1
- package/bin/specweave.js +16 -0
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.js +17 -2
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts +7 -0
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.js +53 -0
- package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.d.ts.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js +17 -2
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js +7 -3
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js +27 -19
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts +8 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-routing.js +10 -7
- package/dist/plugins/specweave-testing/lib/playwright-routing.js.map +1 -1
- package/dist/src/adapters/agents-md-generator.js +1 -1
- package/dist/src/adapters/agents-md-generator.js.map +1 -1
- package/dist/src/adapters/claude/README.md +1 -1
- package/dist/src/adapters/claude-md-generator.js +1 -1
- package/dist/src/adapters/claude-md-generator.js.map +1 -1
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +10 -1
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/commands/refresh-marketplace.d.ts.map +1 -1
- package/dist/src/cli/commands/refresh-marketplace.js +7 -67
- package/dist/src/cli/commands/refresh-marketplace.js.map +1 -1
- package/dist/src/cli/commands/team.d.ts +20 -0
- package/dist/src/cli/commands/team.d.ts.map +1 -0
- package/dist/src/cli/commands/team.js +101 -0
- package/dist/src/cli/commands/team.js.map +1 -0
- package/dist/src/cli/helpers/init/claude-settings-env.d.ts +16 -0
- package/dist/src/cli/helpers/init/claude-settings-env.d.ts.map +1 -0
- package/dist/src/cli/helpers/init/claude-settings-env.js +44 -0
- package/dist/src/cli/helpers/init/claude-settings-env.js.map +1 -0
- package/dist/src/cli/helpers/init/plugin-installer.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/plugin-installer.js +9 -13
- package/dist/src/cli/helpers/init/plugin-installer.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.js +12 -6
- package/dist/src/cli/helpers/issue-tracker/index.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/types.d.ts +2 -0
- package/dist/src/cli/helpers/issue-tracker/types.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/types.js.map +1 -1
- package/dist/src/core/increment/discipline-checker.js +1 -1
- package/dist/src/core/increment/discipline-checker.js.map +1 -1
- package/dist/src/core/increment/status-commands.d.ts.map +1 -1
- package/dist/src/core/increment/status-commands.js +7 -0
- package/dist/src/core/increment/status-commands.js.map +1 -1
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts +2 -2
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts.map +1 -1
- package/dist/src/core/lazy-loading/llm-plugin-detector.js +63 -25
- package/dist/src/core/lazy-loading/llm-plugin-detector.js.map +1 -1
- package/dist/src/core/reflection/reflect-handler.js +2 -2
- package/dist/src/core/reflection/reflect-handler.js.map +1 -1
- package/dist/src/core/session/handoff-context.js +2 -2
- package/dist/src/core/session/handoff-context.js.map +1 -1
- package/dist/src/sync/ado-reconciler.d.ts.map +1 -1
- package/dist/src/sync/ado-reconciler.js +21 -2
- package/dist/src/sync/ado-reconciler.js.map +1 -1
- package/dist/src/sync/engine.d.ts.map +1 -1
- package/dist/src/sync/engine.js +2 -0
- package/dist/src/sync/engine.js.map +1 -1
- package/dist/src/sync/github-reconciler.d.ts.map +1 -1
- package/dist/src/sync/github-reconciler.js +52 -26
- package/dist/src/sync/github-reconciler.js.map +1 -1
- package/dist/src/sync/jira-reconciler.d.ts.map +1 -1
- package/dist/src/sync/jira-reconciler.js +16 -3
- package/dist/src/sync/jira-reconciler.js.map +1 -1
- package/dist/src/sync/providers/ado.d.ts.map +1 -1
- package/dist/src/sync/providers/ado.js +4 -2
- package/dist/src/sync/providers/ado.js.map +1 -1
- package/dist/src/sync/providers/github.d.ts.map +1 -1
- package/dist/src/sync/providers/github.js +11 -0
- package/dist/src/sync/providers/github.js.map +1 -1
- package/dist/src/sync/providers/jira.d.ts.map +1 -1
- package/dist/src/sync/providers/jira.js +14 -2
- package/dist/src/sync/providers/jira.js.map +1 -1
- package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
- package/dist/src/sync/sync-coordinator.js +31 -6
- package/dist/src/sync/sync-coordinator.js.map +1 -1
- package/dist/src/utils/auto-install.js +4 -4
- package/dist/src/utils/auto-install.js.map +1 -1
- package/package.json +2 -2
- package/plugins/FINAL-AUDIT-RECOMMENDATIONS.md +3 -3
- package/plugins/SKILLS-VS-AGENTS.md +1 -1
- package/plugins/specweave/PLUGIN.md +0 -2
- package/plugins/specweave/commands/export-skills.md +1 -1
- package/plugins/specweave/commands/role-orchestrator.md +1 -1
- package/plugins/specweave/hooks/log-decision.sh +6 -0
- package/plugins/specweave/hooks/stop-auto-v5.sh +17 -1
- package/plugins/specweave/hooks/stop-reflect.sh +16 -2
- package/plugins/specweave/hooks/stop-sync.sh +17 -9
- package/plugins/specweave/hooks/user-prompt-submit.sh +119 -35
- package/plugins/specweave/lib/vendor/sync/github-reconciler.js +52 -26
- package/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -1
- package/plugins/specweave/scripts/read-grill-context.sh +149 -0
- package/plugins/specweave/skills/code-review/SKILL.md +608 -0
- package/plugins/specweave/skills/done/SKILL.md +1 -1
- package/plugins/specweave/skills/grill/SKILL.md +91 -0
- package/plugins/specweave/skills/performance/SKILL.md +6 -0
- package/plugins/specweave/skills/security/SKILL.md +7 -0
- package/plugins/specweave/skills/security-patterns/SKILL.md +6 -0
- package/plugins/specweave/skills/tdd-orchestrator/SKILL.md +1 -1
- package/plugins/specweave/skills/team-build/SKILL.md +1 -1
- package/plugins/specweave/skills/team-orchestrate/SKILL.md +1 -1
- package/plugins/specweave/skills/tech-lead/SKILL.md +7 -0
- package/plugins/specweave-ado/lib/ado-permission-gate.js +18 -2
- package/plugins/specweave-ado/lib/ado-permission-gate.ts +19 -2
- package/plugins/specweave-frontend/skills/frontend/SKILL.md +138 -2
- package/plugins/specweave-frontend/skills/i18n-expert/SKILL.md +989 -0
- package/plugins/specweave-github/hooks/github-auto-create-handler.sh +23 -1
- package/plugins/specweave-github/lib/github-feature-sync.js +41 -0
- package/plugins/specweave-github/lib/github-feature-sync.ts +62 -0
- package/plugins/specweave-infrastructure/PLUGIN.md +2 -1
- package/plugins/specweave-infrastructure/skills/gcp-deep-dive/SKILL.md +1172 -0
- package/plugins/specweave-infrastructure/skills/observability/SKILL.md +6 -0
- package/plugins/specweave-infrastructure/skills/opentelemetry/SKILL.md +6 -0
- package/plugins/specweave-jira/lib/jira-permission-gate.js +18 -2
- package/plugins/specweave-jira/lib/jira-permission-gate.ts +19 -2
- package/plugins/specweave-mobile/PLUGIN.md +1 -2
- package/plugins/specweave-mobile/README.md +13 -12
- package/plugins/specweave-mobile/skills/capacitor-ionic/SKILL.md +4 -18
- package/plugins/specweave-mobile/skills/deep-linking-push/SKILL.md +4 -22
- package/plugins/specweave-mobile/skills/expo/SKILL.md +4 -24
- package/plugins/specweave-mobile/skills/mobile-testing/SKILL.md +4 -22
- package/plugins/specweave-mobile/skills/react-native-expert/SKILL.md +404 -47
- package/plugins/specweave-testing/PLUGIN.md +3 -11
- package/plugins/specweave-testing/lib/playwright-cli-detector.js +8 -3
- package/plugins/specweave-testing/lib/playwright-cli-detector.ts +8 -3
- package/plugins/specweave-testing/lib/playwright-cli-runner.js +25 -20
- package/plugins/specweave-testing/lib/playwright-cli-runner.ts +24 -19
- package/plugins/specweave-testing/lib/playwright-routing.js +1 -6
- package/plugins/specweave-testing/lib/playwright-routing.ts +11 -8
- package/plugins/specweave-testing/skills/accessibility-testing/SKILL.md +998 -0
- package/plugins/specweave-testing/skills/e2e-testing/SKILL.md +29 -28
- package/plugins/specweave-testing/skills/mutation-testing/SKILL.md +769 -0
- package/plugins/specweave-testing/skills/performance-testing/SKILL.md +961 -0
- package/plugins/specweave-testing/skills/qa-engineer/SKILL.md +2 -0
- package/plugins/specweave/.specweave/logs/decisions.jsonl +0 -12
- package/plugins/specweave/.specweave/logs/reflect/reflect.log +0 -8
- package/plugins/specweave/.specweave/logs/stop-auto.log +0 -6
- package/plugins/specweave/.specweave/logs/stop-sync.log +0 -10
- package/plugins/specweave/.specweave/state/dashboard.json +0 -43
- package/plugins/specweave/skills/infrastructure/SKILL.md +0 -86
- package/plugins/specweave/skills/qa-lead/SKILL.md +0 -77
- package/plugins/specweave-mobile/skills/mobile-architect/SKILL.md +0 -30
- package/plugins/specweave-testing/commands/e2e-setup.md +0 -1103
- package/plugins/specweave-testing/commands/test-coverage.md +0 -983
- package/plugins/specweave-testing/commands/test-generate.md +0 -1160
- package/plugins/specweave-testing/commands/test-init.md +0 -413
- package/plugins/specweave-testing/commands/ui-automate.md +0 -182
- package/plugins/specweave-testing/commands/ui-inspect.md +0 -82
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Accessibility testing expert for WCAG compliance, axe-core, pa11y, Lighthouse, screen reader testing, keyboard navigation, and CI/CD a11y gates. Use for accessibility audits, a11y test automation, WCAG compliance, screen reader testing, keyboard navigation, color contrast, ARIA patterns, or form accessibility.
|
|
3
|
+
allowed-tools: Read, Write, Edit, Bash
|
|
4
|
+
model: opus
|
|
5
|
+
context: fork
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Accessibility Testing Expert
|
|
9
|
+
|
|
10
|
+
You are an accessibility testing expert with deep knowledge of WCAG standards, automated a11y tooling, assistive technology testing, and inclusive design validation.
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
Trigger this skill for: "accessibility", "a11y", "WCAG", "screen reader", "axe", "axe-core", "pa11y", "keyboard navigation", "color contrast", "ARIA", "focus management", "touch target", "Lighthouse accessibility", "VoiceOver", "NVDA", "TalkBack", "skip navigation", "focus trap", "reduced motion", "form accessibility", "label association".
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Automated Accessibility Testing
|
|
19
|
+
|
|
20
|
+
### axe-core + Playwright
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -D @axe-core/playwright axe-core
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
#### Full Page Audit
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { test, expect } from '@playwright/test';
|
|
30
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
31
|
+
|
|
32
|
+
test.describe('Accessibility', () => {
|
|
33
|
+
test('homepage has no WCAG 2.1 AA violations', async ({ page }) => {
|
|
34
|
+
await page.goto('/');
|
|
35
|
+
|
|
36
|
+
const results = await new AxeBuilder({ page })
|
|
37
|
+
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
|
|
38
|
+
.analyze();
|
|
39
|
+
|
|
40
|
+
expect(results.violations).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('login page is accessible', async ({ page }) => {
|
|
44
|
+
await page.goto('/login');
|
|
45
|
+
|
|
46
|
+
const results = await new AxeBuilder({ page })
|
|
47
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
48
|
+
.exclude('.third-party-widget')
|
|
49
|
+
.analyze();
|
|
50
|
+
|
|
51
|
+
expect(results.violations).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Scoped and Dynamic Content Audits
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Scoped to a specific region
|
|
60
|
+
test('navigation is accessible', async ({ page }) => {
|
|
61
|
+
await page.goto('/');
|
|
62
|
+
const results = await new AxeBuilder({ page })
|
|
63
|
+
.include('[role="navigation"]')
|
|
64
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
65
|
+
.analyze();
|
|
66
|
+
expect(results.violations).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// After interaction (modals, dropdowns)
|
|
70
|
+
test('modal is accessible after opening', async ({ page }) => {
|
|
71
|
+
await page.goto('/dashboard');
|
|
72
|
+
await page.getByRole('button', { name: 'Settings' }).click();
|
|
73
|
+
await page.waitForSelector('[role="dialog"]');
|
|
74
|
+
|
|
75
|
+
const results = await new AxeBuilder({ page })
|
|
76
|
+
.include('[role="dialog"]')
|
|
77
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
78
|
+
.analyze();
|
|
79
|
+
expect(results.violations).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
#### Custom Rules and Violation Reporting
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// Disable specific rules when justified
|
|
87
|
+
const results = await new AxeBuilder({ page })
|
|
88
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
89
|
+
.disableRules(['color-contrast'])
|
|
90
|
+
.options({ rules: { 'region': { enabled: true } } })
|
|
91
|
+
.analyze();
|
|
92
|
+
|
|
93
|
+
// Detailed violation logging
|
|
94
|
+
if (results.violations.length > 0) {
|
|
95
|
+
const report = results.violations.map((v) => ({
|
|
96
|
+
rule: v.id, impact: v.impact, description: v.description,
|
|
97
|
+
helpUrl: v.helpUrl,
|
|
98
|
+
nodes: v.nodes.map((n) => ({ html: n.html, target: n.target, failureSummary: n.failureSummary })),
|
|
99
|
+
}));
|
|
100
|
+
console.error('A11y violations:', JSON.stringify(report, null, 2));
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### axe-core + Jest / Vitest (Component Testing)
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install -D jest-axe @types/jest-axe
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { render } from '@testing-library/react';
|
|
112
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
113
|
+
|
|
114
|
+
expect.extend(toHaveNoViolations);
|
|
115
|
+
|
|
116
|
+
describe('Button component', () => {
|
|
117
|
+
it('should have no accessibility violations', async () => {
|
|
118
|
+
const { container } = render(
|
|
119
|
+
<button type="button" aria-label="Close dialog">X</button>
|
|
120
|
+
);
|
|
121
|
+
const results = await axe(container);
|
|
122
|
+
expect(results).toHaveNoViolations();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### pa11y CLI and CI Integration
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm install -D pa11y pa11y-ci
|
|
131
|
+
|
|
132
|
+
# Single page audit
|
|
133
|
+
npx pa11y --standard WCAG2AA https://example.com
|
|
134
|
+
|
|
135
|
+
# JSON output for CI
|
|
136
|
+
npx pa11y --reporter json https://example.com > a11y-report.json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
#### pa11y-ci Configuration
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
// .pa11yci.json
|
|
143
|
+
{
|
|
144
|
+
"defaults": {
|
|
145
|
+
"standard": "WCAG2AA",
|
|
146
|
+
"timeout": 30000,
|
|
147
|
+
"chromeLaunchConfig": { "args": ["--no-sandbox"] }
|
|
148
|
+
},
|
|
149
|
+
"urls": [
|
|
150
|
+
"http://localhost:3000/",
|
|
151
|
+
"http://localhost:3000/login",
|
|
152
|
+
{
|
|
153
|
+
"url": "http://localhost:3000/settings",
|
|
154
|
+
"actions": ["wait for element #settings-form to be visible"]
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Lighthouse Accessibility Scoring
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# CLI audit
|
|
164
|
+
npx lighthouse https://example.com \
|
|
165
|
+
--only-categories=accessibility \
|
|
166
|
+
--chrome-flags="--headless --no-sandbox" \
|
|
167
|
+
--output=html --output-path=./a11y-report.html
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### Lighthouse CI Configuration
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
// lighthouserc.json
|
|
174
|
+
{
|
|
175
|
+
"ci": {
|
|
176
|
+
"collect": {
|
|
177
|
+
"url": ["http://localhost:3000/", "http://localhost:3000/login"],
|
|
178
|
+
"startServerCommand": "npm run start",
|
|
179
|
+
"numberOfRuns": 3
|
|
180
|
+
},
|
|
181
|
+
"assert": {
|
|
182
|
+
"assertions": {
|
|
183
|
+
"categories:accessibility": ["error", { "minScore": 0.9 }]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 2. WCAG 2.1 Compliance Testing
|
|
193
|
+
|
|
194
|
+
### Level AA Requirements Checklist
|
|
195
|
+
|
|
196
|
+
#### Perceivable
|
|
197
|
+
|
|
198
|
+
| Criterion | ID | Test Method |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| Non-text content has text alternatives | 1.1.1 | axe: `image-alt`, `input-image-alt`, `area-alt` |
|
|
201
|
+
| Captions for prerecorded audio/video | 1.2.1-1.2.5 | Manual review |
|
|
202
|
+
| Audio description for video | 1.2.3, 1.2.5 | Manual review |
|
|
203
|
+
| Info not conveyed by color alone | 1.3.1 | axe: `color-contrast`, manual review |
|
|
204
|
+
| Meaningful sequence preserved | 1.3.2 | Manual: linearize page, verify reading order |
|
|
205
|
+
| Sensory characteristics not sole instruction | 1.3.3 | Manual review |
|
|
206
|
+
| Content orientation not restricted | 1.3.4 | Rotate device/viewport, verify layout |
|
|
207
|
+
| Input purpose identifiable | 1.3.5 | Check `autocomplete` attributes on form fields |
|
|
208
|
+
| Contrast ratio >= 4.5:1 (text) | 1.4.3 | axe: `color-contrast` |
|
|
209
|
+
| Text resizable to 200% without loss | 1.4.4 | Browser zoom to 200%, verify no content loss |
|
|
210
|
+
| No images of text | 1.4.5 | Manual review |
|
|
211
|
+
| Reflow at 320px width | 1.4.10 | Set viewport to 320px, verify no horizontal scroll |
|
|
212
|
+
| Non-text contrast >= 3:1 | 1.4.11 | Manual: check UI component and graphic borders |
|
|
213
|
+
| Text spacing adjustable | 1.4.12 | Apply text spacing override, verify no content loss |
|
|
214
|
+
| Hover/focus content dismissible | 1.4.13 | Manual: test tooltips, popovers |
|
|
215
|
+
|
|
216
|
+
#### Operable
|
|
217
|
+
|
|
218
|
+
| Criterion | ID | Test Method |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| All functionality via keyboard | 2.1.1 | Tab through entire page |
|
|
221
|
+
| No keyboard traps | 2.1.2 | Verify Escape/Tab can leave all components |
|
|
222
|
+
| Timing adjustable or removable | 2.2.1 | Check for session timeouts |
|
|
223
|
+
| Pause, stop, hide moving content | 2.2.2 | Verify animations can be paused |
|
|
224
|
+
| No content flashes > 3/sec | 2.3.1 | Visual inspection |
|
|
225
|
+
| Skip navigation mechanism | 2.4.1 | Tab from top, verify skip link |
|
|
226
|
+
| Pages have descriptive titles | 2.4.2 | Check `<title>` per page |
|
|
227
|
+
| Logical focus order | 2.4.3 | Tab through and verify order |
|
|
228
|
+
| Link purpose clear from text | 2.4.4 | Review link text (no "click here") |
|
|
229
|
+
| Multiple ways to find pages | 2.4.5 | Verify sitemap, search, or nav |
|
|
230
|
+
| Headings and labels descriptive | 2.4.6 | Review heading hierarchy |
|
|
231
|
+
| Focus indicator visible | 2.4.7 | Tab through, verify focus rings |
|
|
232
|
+
| Pointer gestures have alternatives | 2.5.1 | Test without multi-touch |
|
|
233
|
+
| Pointer cancellation supported | 2.5.2 | Verify action on up-event |
|
|
234
|
+
| Label in name matches visible text | 2.5.3 | Compare `aria-label` with visible text |
|
|
235
|
+
| Motion actuation has alternatives | 2.5.4 | Test without device motion |
|
|
236
|
+
|
|
237
|
+
#### Understandable
|
|
238
|
+
|
|
239
|
+
| Criterion | ID | Test Method |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| Page language defined | 3.1.1 | Check `<html lang="...">`, axe: `html-has-lang` |
|
|
242
|
+
| Parts in different language marked | 3.1.2 | Check `lang` on foreign-language elements |
|
|
243
|
+
| Consistent navigation | 3.2.3 | Compare nav across pages |
|
|
244
|
+
| Consistent identification | 3.2.4 | Same function = same label across pages |
|
|
245
|
+
| Error identified and described | 3.3.1 | Submit invalid form, verify error text |
|
|
246
|
+
| Labels or instructions provided | 3.3.2 | Check form labels, axe: `label` |
|
|
247
|
+
| Error suggestion provided | 3.3.3 | Submit invalid input, check suggestions |
|
|
248
|
+
| Error prevention on legal/financial | 3.3.4 | Verify confirm/review step |
|
|
249
|
+
| On focus: no context change | 3.2.1 | Tab to elements, verify stability |
|
|
250
|
+
| On input: no context change | 3.2.2 | Change inputs, verify no unexpected navigation |
|
|
251
|
+
|
|
252
|
+
#### Robust
|
|
253
|
+
|
|
254
|
+
| Criterion | ID | Test Method |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| Valid HTML parsing | 4.1.1 | HTML validator (W3C), axe: `duplicate-id` |
|
|
257
|
+
| Name, role, value for components | 4.1.2 | axe: `aria-roles`, `aria-valid-attr` |
|
|
258
|
+
| Status messages programmatically exposed | 4.1.3 | Test `role="status"`, `role="alert"` |
|
|
259
|
+
|
|
260
|
+
### Level AAA Notable Criteria
|
|
261
|
+
|
|
262
|
+
Not typically required, but worth targeting for enhanced accessibility:
|
|
263
|
+
|
|
264
|
+
- **1.4.6** Enhanced contrast (7:1)
|
|
265
|
+
- **1.4.8** Visual presentation (line length, spacing)
|
|
266
|
+
- **2.4.9** Link purpose from link text alone
|
|
267
|
+
- **2.4.10** Section headings used
|
|
268
|
+
- **2.5.5** Touch target size 44x44px
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
test('enhanced contrast AAA', async ({ page }) => {
|
|
272
|
+
await page.goto('/');
|
|
273
|
+
const results = await new AxeBuilder({ page }).withTags(['wcag2aaa']).analyze();
|
|
274
|
+
if (results.violations.length > 0) {
|
|
275
|
+
console.warn('AAA violations:', results.violations.map((v) => v.id));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Testing by WCAG Principle
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
// Perceivable: images have alt text
|
|
284
|
+
test('images have alt text', async ({ page }) => {
|
|
285
|
+
await page.goto('/');
|
|
286
|
+
const images = await page.locator('img').all();
|
|
287
|
+
for (const img of images) {
|
|
288
|
+
const alt = await img.getAttribute('alt');
|
|
289
|
+
const role = await img.getAttribute('role');
|
|
290
|
+
expect(alt !== null || role === 'presentation').toBeTruthy();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Understandable: page language defined
|
|
295
|
+
test('page language defined', async ({ page }) => {
|
|
296
|
+
await page.goto('/');
|
|
297
|
+
const lang = await page.locator('html').getAttribute('lang');
|
|
298
|
+
expect(lang).toBeTruthy();
|
|
299
|
+
expect(lang!.length).toBeGreaterThanOrEqual(2);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Robust: no duplicate IDs
|
|
303
|
+
test('no duplicate element IDs', async ({ page }) => {
|
|
304
|
+
await page.goto('/');
|
|
305
|
+
const duplicates = await page.evaluate(() => {
|
|
306
|
+
const ids = Array.from(document.querySelectorAll('[id]')).map((el) => el.id);
|
|
307
|
+
return ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
308
|
+
});
|
|
309
|
+
expect(duplicates).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## 3. Screen Reader Testing Patterns
|
|
316
|
+
|
|
317
|
+
### VoiceOver (macOS)
|
|
318
|
+
|
|
319
|
+
**Activation**: Cmd + F5
|
|
320
|
+
|
|
321
|
+
| Action | Keys |
|
|
322
|
+
|---|---|
|
|
323
|
+
| Start/stop VoiceOver | Cmd + F5 |
|
|
324
|
+
| Read next item | VO + Right Arrow (VO = Ctrl + Option) |
|
|
325
|
+
| Read previous item | VO + Left Arrow |
|
|
326
|
+
| Activate element | VO + Space |
|
|
327
|
+
| Heading list (rotor) | VO + U, then left/right to headings |
|
|
328
|
+
| Landmark list (rotor) | VO + U, navigate to landmarks |
|
|
329
|
+
|
|
330
|
+
**Manual testing checklist**: Navigate in Safari, verify headings/landmarks in rotor, check form label announcements, verify alt text on images, confirm live region updates, verify focus returns after dialog close.
|
|
331
|
+
|
|
332
|
+
### NVDA (Windows)
|
|
333
|
+
|
|
334
|
+
| Action | Keys |
|
|
335
|
+
|---|---|
|
|
336
|
+
| Start/stop NVDA | Ctrl + Alt + N |
|
|
337
|
+
| Read next item | Down Arrow (browse mode) |
|
|
338
|
+
| Heading list | NVDA + F7 |
|
|
339
|
+
| Next heading | H |
|
|
340
|
+
| Next landmark | D |
|
|
341
|
+
| Forms mode | Enter (on form field) |
|
|
342
|
+
| Browse mode | Escape |
|
|
343
|
+
|
|
344
|
+
### TalkBack (Android)
|
|
345
|
+
|
|
346
|
+
| Action | Gesture |
|
|
347
|
+
|---|---|
|
|
348
|
+
| Read next item | Swipe right |
|
|
349
|
+
| Read previous item | Swipe left |
|
|
350
|
+
| Activate | Double tap |
|
|
351
|
+
| Scroll | Two-finger swipe |
|
|
352
|
+
|
|
353
|
+
### Testing Live Regions
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
test('status updates announced via live region', async ({ page }) => {
|
|
357
|
+
await page.goto('/form');
|
|
358
|
+
const liveRegion = page.locator('[aria-live="polite"], [role="status"]');
|
|
359
|
+
await expect(liveRegion).toBeAttached();
|
|
360
|
+
|
|
361
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
362
|
+
await expect(liveRegion).toHaveText(/saved successfully/i);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('error alerts use assertive live region', async ({ page }) => {
|
|
366
|
+
await page.goto('/form');
|
|
367
|
+
const alertRegion = page.locator('[aria-live="assertive"], [role="alert"]');
|
|
368
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
369
|
+
await expect(alertRegion).toContainText(/required/i);
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Testing ARIA Labels
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
test('interactive elements have accessible names', async ({ page }) => {
|
|
377
|
+
await page.goto('/');
|
|
378
|
+
|
|
379
|
+
for (const role of ['button', 'link'] as const) {
|
|
380
|
+
const elements = await page.getByRole(role).all();
|
|
381
|
+
for (const el of elements) {
|
|
382
|
+
const name = await el.evaluate((e) =>
|
|
383
|
+
e.getAttribute('aria-label') || e.getAttribute('aria-labelledby') || e.textContent?.trim()
|
|
384
|
+
);
|
|
385
|
+
expect(name).toBeTruthy();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Common ARIA Mistakes and Fixes
|
|
392
|
+
|
|
393
|
+
| Mistake | Fix |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `<div onclick="...">` | Use `<button>` or add `role="button"` + `tabindex="0"` + keydown handler |
|
|
396
|
+
| `aria-label` on non-interactive `<div>` | Use `aria-label` only on interactive or landmark elements |
|
|
397
|
+
| `role="button"` without keyboard support | Add `tabindex="0"` and keydown handler for Enter/Space |
|
|
398
|
+
| `aria-hidden="true"` on focusable elements | Remove from focusable elements or add `tabindex="-1"` |
|
|
399
|
+
| Redundant `role="navigation"` on `<nav>` | Remove explicit role; `<nav>` has implicit navigation role |
|
|
400
|
+
| Missing `aria-expanded` on toggles | Add `aria-expanded="true/false"` to disclosure buttons |
|
|
401
|
+
| `aria-labelledby` pointing to missing ID | Ensure referenced ID exists in the DOM |
|
|
402
|
+
| `aria-label` differs from visible text | Match `aria-label` with visible label (WCAG 2.5.3) |
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
test('no common ARIA mistakes', async ({ page }) => {
|
|
406
|
+
await page.goto('/');
|
|
407
|
+
|
|
408
|
+
// Clickable divs without proper roles
|
|
409
|
+
const clickableDivs = await page.evaluate(() =>
|
|
410
|
+
Array.from(document.querySelectorAll('div[onclick], span[onclick]'))
|
|
411
|
+
.filter((el) => !el.getAttribute('role') && !el.getAttribute('tabindex'))
|
|
412
|
+
.map((el) => el.outerHTML.substring(0, 100))
|
|
413
|
+
);
|
|
414
|
+
expect(clickableDivs).toEqual([]);
|
|
415
|
+
|
|
416
|
+
// aria-hidden on focusable elements
|
|
417
|
+
const hiddenFocusable = await page.evaluate(() =>
|
|
418
|
+
Array.from(document.querySelectorAll('[aria-hidden="true"] a, [aria-hidden="true"] button, [aria-hidden="true"] input'))
|
|
419
|
+
.filter((el) => el.getAttribute('tabindex') !== '-1')
|
|
420
|
+
.map((el) => el.outerHTML.substring(0, 100))
|
|
421
|
+
);
|
|
422
|
+
expect(hiddenFocusable).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## 4. Keyboard Navigation Testing
|
|
429
|
+
|
|
430
|
+
### Tab Order Verification
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
test('tab order follows logical reading order', async ({ page }) => {
|
|
434
|
+
await page.goto('/');
|
|
435
|
+
|
|
436
|
+
const expectedOrder = ['Skip to main content', 'Home', 'Products', 'About', 'Contact', 'Search'];
|
|
437
|
+
|
|
438
|
+
for (const expectedLabel of expectedOrder) {
|
|
439
|
+
await page.keyboard.press('Tab');
|
|
440
|
+
const focused = await page.evaluate(() => {
|
|
441
|
+
const el = document.activeElement;
|
|
442
|
+
return el?.getAttribute('aria-label') || el?.textContent?.trim() || '';
|
|
443
|
+
});
|
|
444
|
+
expect(focused).toContain(expectedLabel);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('shift+tab navigates backwards', async ({ page }) => {
|
|
449
|
+
await page.goto('/');
|
|
450
|
+
for (let i = 0; i < 5; i++) await page.keyboard.press('Tab');
|
|
451
|
+
const fifth = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
|
452
|
+
await page.keyboard.press('Shift+Tab');
|
|
453
|
+
const fourth = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
|
454
|
+
expect(fourth).not.toBe(fifth);
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Focus Trap Testing
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
test('modal traps focus within dialog', async ({ page }) => {
|
|
462
|
+
await page.goto('/');
|
|
463
|
+
await page.getByRole('button', { name: 'Open modal' }).click();
|
|
464
|
+
await page.waitForSelector('[role="dialog"]');
|
|
465
|
+
|
|
466
|
+
const dialog = page.locator('[role="dialog"]');
|
|
467
|
+
const focusableInModal = await dialog.locator(
|
|
468
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
469
|
+
).all();
|
|
470
|
+
|
|
471
|
+
expect(focusableInModal.length).toBeGreaterThan(0);
|
|
472
|
+
|
|
473
|
+
// Tab through and verify focus stays in modal
|
|
474
|
+
for (let i = 0; i < focusableInModal.length + 2; i++) {
|
|
475
|
+
await page.keyboard.press('Tab');
|
|
476
|
+
const inside = await page.evaluate(() => {
|
|
477
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
478
|
+
return dialog?.contains(document.activeElement) ?? false;
|
|
479
|
+
});
|
|
480
|
+
expect(inside).toBe(true);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Escape closes modal, focus returns to trigger
|
|
484
|
+
await page.keyboard.press('Escape');
|
|
485
|
+
await expect(dialog).not.toBeVisible();
|
|
486
|
+
const triggerFocused = await page.getByRole('button', { name: 'Open modal' }).evaluate(
|
|
487
|
+
(el) => el === document.activeElement
|
|
488
|
+
);
|
|
489
|
+
expect(triggerFocused).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Skip Navigation Links
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
test('skip navigation link works', async ({ page }) => {
|
|
497
|
+
await page.goto('/');
|
|
498
|
+
await page.keyboard.press('Tab');
|
|
499
|
+
|
|
500
|
+
const skipLink = page.locator('a[href="#main-content"], a[href="#main"]');
|
|
501
|
+
await expect(skipLink).toBeFocused();
|
|
502
|
+
await expect(skipLink).toBeVisible();
|
|
503
|
+
|
|
504
|
+
await page.keyboard.press('Enter');
|
|
505
|
+
const mainFocused = await page.evaluate(() => {
|
|
506
|
+
const active = document.activeElement;
|
|
507
|
+
return active?.id === 'main-content' || active?.id === 'main' || active?.closest('main') !== null;
|
|
508
|
+
});
|
|
509
|
+
expect(mainFocused).toBe(true);
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Custom Keyboard Interactions
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
test('dropdown menu supports keyboard navigation', async ({ page }) => {
|
|
517
|
+
await page.goto('/');
|
|
518
|
+
const menuButton = page.getByRole('button', { name: 'Menu' });
|
|
519
|
+
await menuButton.focus();
|
|
520
|
+
|
|
521
|
+
await page.keyboard.press('Enter');
|
|
522
|
+
const menu = page.getByRole('menu');
|
|
523
|
+
await expect(menu).toBeVisible();
|
|
524
|
+
|
|
525
|
+
await page.keyboard.press('ArrowDown');
|
|
526
|
+
await expect(page.getByRole('menuitem').first()).toBeFocused();
|
|
527
|
+
|
|
528
|
+
await page.keyboard.press('ArrowDown');
|
|
529
|
+
await expect(page.getByRole('menuitem').nth(1)).toBeFocused();
|
|
530
|
+
|
|
531
|
+
await page.keyboard.press('Escape');
|
|
532
|
+
await expect(menu).not.toBeVisible();
|
|
533
|
+
await expect(menuButton).toBeFocused();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('tabs support arrow key navigation', async ({ page }) => {
|
|
537
|
+
await page.goto('/tabs-page');
|
|
538
|
+
const tabs = page.getByRole('tablist').getByRole('tab');
|
|
539
|
+
|
|
540
|
+
await tabs.first().focus();
|
|
541
|
+
await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
|
|
542
|
+
|
|
543
|
+
await page.keyboard.press('ArrowRight');
|
|
544
|
+
await expect(tabs.nth(1)).toBeFocused();
|
|
545
|
+
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
|
|
546
|
+
await expect(page.getByRole('tabpanel')).toBeVisible();
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## 5. Visual Accessibility
|
|
553
|
+
|
|
554
|
+
### Color Contrast Testing
|
|
555
|
+
|
|
556
|
+
WCAG contrast requirements:
|
|
557
|
+
- **AA normal text** (< 18pt / < 14pt bold): 4.5:1
|
|
558
|
+
- **AA large text** (>= 18pt / >= 14pt bold): 3:1
|
|
559
|
+
- **AAA normal text**: 7:1
|
|
560
|
+
- **AAA large text**: 4.5:1
|
|
561
|
+
- **Non-text UI components**: 3:1
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
test('color contrast meets WCAG AA', async ({ page }) => {
|
|
565
|
+
await page.goto('/');
|
|
566
|
+
const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
|
|
567
|
+
expect(results.violations).toEqual([]);
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### prefers-reduced-motion Testing
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
test('animations respect prefers-reduced-motion', async ({ page }) => {
|
|
575
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
576
|
+
await page.goto('/');
|
|
577
|
+
|
|
578
|
+
const hasAnimations = await page.evaluate(() => {
|
|
579
|
+
for (const el of document.querySelectorAll('*')) {
|
|
580
|
+
const styles = window.getComputedStyle(el);
|
|
581
|
+
if (parseFloat(styles.animationDuration) > 0 || parseFloat(styles.transitionDuration) > 0) {
|
|
582
|
+
if (!el.hasAttribute('data-essential-animation')) return true;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return false;
|
|
586
|
+
});
|
|
587
|
+
expect(hasAnimations).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### prefers-color-scheme Testing
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
test.describe('Dark mode accessibility', () => {
|
|
595
|
+
test('dark mode maintains contrast ratios', async ({ page }) => {
|
|
596
|
+
await page.emulateMedia({ colorScheme: 'dark' });
|
|
597
|
+
await page.goto('/');
|
|
598
|
+
const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
|
|
599
|
+
expect(results.violations).toEqual([]);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('light mode maintains contrast ratios', async ({ page }) => {
|
|
603
|
+
await page.emulateMedia({ colorScheme: 'light' });
|
|
604
|
+
await page.goto('/');
|
|
605
|
+
const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
|
|
606
|
+
expect(results.violations).toEqual([]);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Touch Target Size Validation
|
|
612
|
+
|
|
613
|
+
WCAG 2.5.8 (AA): Minimum 24x24px. Best practice (AAA 2.5.5): 44x44px.
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
test('touch targets meet minimum size', async ({ page }) => {
|
|
617
|
+
await page.goto('/');
|
|
618
|
+
|
|
619
|
+
const smallTargets = await page.evaluate(() => {
|
|
620
|
+
const elements = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]');
|
|
621
|
+
const violations: Array<{ html: string; width: number; height: number }> = [];
|
|
622
|
+
|
|
623
|
+
elements.forEach((el) => {
|
|
624
|
+
const rect = el.getBoundingClientRect();
|
|
625
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
626
|
+
if (el.tagName === 'A' && el.closest('p')) return; // Skip inline text links
|
|
627
|
+
|
|
628
|
+
if (rect.width < 44 || rect.height < 44) {
|
|
629
|
+
violations.push({
|
|
630
|
+
html: (el as HTMLElement).outerHTML.substring(0, 120),
|
|
631
|
+
width: Math.round(rect.width),
|
|
632
|
+
height: Math.round(rect.height),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
return violations;
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
expect(smallTargets).toEqual([]);
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
## 6. Form Accessibility
|
|
646
|
+
|
|
647
|
+
### Label Association Testing
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
test('all form inputs have associated labels', async ({ page }) => {
|
|
651
|
+
await page.goto('/form');
|
|
652
|
+
|
|
653
|
+
const unlabeledInputs = await page.evaluate(() => {
|
|
654
|
+
const inputs = document.querySelectorAll(
|
|
655
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), select, textarea'
|
|
656
|
+
);
|
|
657
|
+
const violations: string[] = [];
|
|
658
|
+
|
|
659
|
+
inputs.forEach((input) => {
|
|
660
|
+
const id = input.getAttribute('id');
|
|
661
|
+
const hasExplicitLabel = id ? document.querySelector(`label[for="${id}"]`) !== null : false;
|
|
662
|
+
const hasImplicitLabel = input.closest('label') !== null;
|
|
663
|
+
const hasAria = input.getAttribute('aria-label') || input.getAttribute('aria-labelledby') || input.getAttribute('title');
|
|
664
|
+
|
|
665
|
+
if (!hasExplicitLabel && !hasImplicitLabel && !hasAria) {
|
|
666
|
+
violations.push((input as HTMLElement).outerHTML.substring(0, 100));
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return violations;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(unlabeledInputs).toEqual([]);
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Error Message Announcement
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
test('form errors are announced to screen readers', async ({ page }) => {
|
|
680
|
+
await page.goto('/form');
|
|
681
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
682
|
+
|
|
683
|
+
// Error messages should be in alert or live region
|
|
684
|
+
const errorAnnouncement = await page.evaluate(() => {
|
|
685
|
+
const errors = document.querySelectorAll('[role="alert"], [aria-live="assertive"], [aria-live="polite"]');
|
|
686
|
+
return Array.from(errors).map((el) => el.textContent?.trim()).filter(Boolean);
|
|
687
|
+
});
|
|
688
|
+
expect(errorAnnouncement.length).toBeGreaterThan(0);
|
|
689
|
+
|
|
690
|
+
// Each invalid field should have aria-describedby pointing to error
|
|
691
|
+
const invalidFields = await page.locator('[aria-invalid="true"]').all();
|
|
692
|
+
expect(invalidFields.length).toBeGreaterThan(0);
|
|
693
|
+
|
|
694
|
+
for (const field of invalidFields) {
|
|
695
|
+
const describedBy = await field.getAttribute('aria-describedby');
|
|
696
|
+
expect(describedBy).toBeTruthy();
|
|
697
|
+
const errorEl = page.locator(`#${describedBy}`);
|
|
698
|
+
await expect(errorEl).toBeAttached();
|
|
699
|
+
expect((await errorEl.textContent())?.trim().length).toBeGreaterThan(0);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### Required Field Indication
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
test('required fields are properly indicated', async ({ page }) => {
|
|
708
|
+
await page.goto('/form');
|
|
709
|
+
|
|
710
|
+
const requiredFields = await page.locator('[required], [aria-required="true"]').all();
|
|
711
|
+
expect(requiredFields.length).toBeGreaterThan(0);
|
|
712
|
+
|
|
713
|
+
for (const field of requiredFields) {
|
|
714
|
+
const id = await field.getAttribute('id');
|
|
715
|
+
const label = id ? page.locator(`label[for="${id}"]`) : page.locator('label').filter({ has: field });
|
|
716
|
+
|
|
717
|
+
if (await label.count() > 0) {
|
|
718
|
+
const labelText = await label.first().textContent();
|
|
719
|
+
const hasIndicator = labelText?.includes('*') || labelText?.toLowerCase().includes('required');
|
|
720
|
+
const hasAriaRequired = await field.getAttribute('aria-required') === 'true';
|
|
721
|
+
const hasRequired = await field.getAttribute('required') !== null;
|
|
722
|
+
expect(hasIndicator || hasAriaRequired || hasRequired).toBe(true);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### Validation Message Patterns
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
test('inline validation messages are accessible', async ({ page }) => {
|
|
732
|
+
await page.goto('/form');
|
|
733
|
+
|
|
734
|
+
const emailField = page.getByLabel('Email');
|
|
735
|
+
await emailField.fill('invalid-email');
|
|
736
|
+
await emailField.blur();
|
|
737
|
+
await page.waitForSelector('[role="alert"], .error-message');
|
|
738
|
+
|
|
739
|
+
await expect(emailField).toHaveAttribute('aria-invalid', 'true');
|
|
740
|
+
const describedBy = await emailField.getAttribute('aria-describedby');
|
|
741
|
+
expect(describedBy).toBeTruthy();
|
|
742
|
+
|
|
743
|
+
// Fix value and verify error clears
|
|
744
|
+
await emailField.fill('valid@example.com');
|
|
745
|
+
await emailField.blur();
|
|
746
|
+
const invalidAttr = await emailField.getAttribute('aria-invalid');
|
|
747
|
+
expect(invalidAttr === null || invalidAttr === 'false').toBe(true);
|
|
748
|
+
});
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
### Reference: Accessible Form Pattern
|
|
752
|
+
|
|
753
|
+
```html
|
|
754
|
+
<form novalidate aria-labelledby="form-title">
|
|
755
|
+
<h2 id="form-title">Contact Us</h2>
|
|
756
|
+
<p>Fields marked with * are required.</p>
|
|
757
|
+
|
|
758
|
+
<div class="field">
|
|
759
|
+
<label for="name">Full Name *</label>
|
|
760
|
+
<input id="name" type="text" required aria-required="true"
|
|
761
|
+
aria-describedby="name-hint name-error" autocomplete="name" />
|
|
762
|
+
<span id="name-hint" class="hint">Enter your first and last name</span>
|
|
763
|
+
<span id="name-error" class="error" role="alert" aria-live="assertive"></span>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div class="field">
|
|
767
|
+
<label for="email">Email Address *</label>
|
|
768
|
+
<input id="email" type="email" required aria-required="true"
|
|
769
|
+
aria-describedby="email-error" autocomplete="email" />
|
|
770
|
+
<span id="email-error" class="error" role="alert" aria-live="assertive"></span>
|
|
771
|
+
</div>
|
|
772
|
+
|
|
773
|
+
<fieldset>
|
|
774
|
+
<legend>Preferred Contact Method</legend>
|
|
775
|
+
<div>
|
|
776
|
+
<input type="radio" id="contact-email" name="contact" value="email" />
|
|
777
|
+
<label for="contact-email">Email</label>
|
|
778
|
+
</div>
|
|
779
|
+
<div>
|
|
780
|
+
<input type="radio" id="contact-phone" name="contact" value="phone" />
|
|
781
|
+
<label for="contact-phone">Phone</label>
|
|
782
|
+
</div>
|
|
783
|
+
</fieldset>
|
|
784
|
+
|
|
785
|
+
<button type="submit">Send Message</button>
|
|
786
|
+
</form>
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
---
|
|
790
|
+
|
|
791
|
+
## 7. CI/CD Integration
|
|
792
|
+
|
|
793
|
+
### GitHub Actions Accessibility Gate
|
|
794
|
+
|
|
795
|
+
```yaml
|
|
796
|
+
# .github/workflows/accessibility.yml
|
|
797
|
+
name: Accessibility Tests
|
|
798
|
+
|
|
799
|
+
on:
|
|
800
|
+
pull_request:
|
|
801
|
+
branches: [main, develop]
|
|
802
|
+
push:
|
|
803
|
+
branches: [main]
|
|
804
|
+
|
|
805
|
+
jobs:
|
|
806
|
+
a11y:
|
|
807
|
+
runs-on: ubuntu-latest
|
|
808
|
+
steps:
|
|
809
|
+
- uses: actions/checkout@v4
|
|
810
|
+
- uses: actions/setup-node@v4
|
|
811
|
+
with:
|
|
812
|
+
node-version: 20
|
|
813
|
+
cache: 'npm'
|
|
814
|
+
- run: npm ci
|
|
815
|
+
- run: npx playwright install --with-deps chromium
|
|
816
|
+
- run: npm run build
|
|
817
|
+
- name: Start app
|
|
818
|
+
run: npm run start &
|
|
819
|
+
env:
|
|
820
|
+
PORT: 3000
|
|
821
|
+
- run: npx wait-on http://localhost:3000 --timeout 30000
|
|
822
|
+
- name: axe-core tests
|
|
823
|
+
run: npx playwright test --project=accessibility
|
|
824
|
+
- name: pa11y-ci
|
|
825
|
+
run: npx pa11y-ci --config .pa11yci.json
|
|
826
|
+
- name: Lighthouse
|
|
827
|
+
uses: treosh/lighthouse-ci-action@v12
|
|
828
|
+
with:
|
|
829
|
+
configPath: ./lighthouserc.json
|
|
830
|
+
uploadArtifacts: true
|
|
831
|
+
- name: Upload report
|
|
832
|
+
if: always()
|
|
833
|
+
uses: actions/upload-artifact@v4
|
|
834
|
+
with:
|
|
835
|
+
name: accessibility-report
|
|
836
|
+
path: playwright-report/
|
|
837
|
+
retention-days: 14
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
### Blocking on Critical/Serious Violations
|
|
841
|
+
|
|
842
|
+
```typescript
|
|
843
|
+
// tests/accessibility/a11y-gate.spec.ts
|
|
844
|
+
import { test, expect } from '@playwright/test';
|
|
845
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
846
|
+
|
|
847
|
+
const pages = ['/', '/login', '/dashboard', '/settings', '/form'];
|
|
848
|
+
|
|
849
|
+
for (const pagePath of pages) {
|
|
850
|
+
test(`${pagePath} has no critical/serious a11y violations`, async ({ page }) => {
|
|
851
|
+
await page.goto(pagePath);
|
|
852
|
+
|
|
853
|
+
const results = await new AxeBuilder({ page })
|
|
854
|
+
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
|
|
855
|
+
.analyze();
|
|
856
|
+
|
|
857
|
+
const critical = results.violations.filter(
|
|
858
|
+
(v) => v.impact === 'critical' || v.impact === 'serious'
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
if (critical.length > 0) {
|
|
862
|
+
console.error(`Violations on ${pagePath}:`, JSON.stringify(
|
|
863
|
+
critical.map((v) => ({ rule: v.id, impact: v.impact, count: v.nodes.length })), null, 2
|
|
864
|
+
));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Block on critical/serious; warn on moderate/minor
|
|
868
|
+
expect(critical).toEqual([]);
|
|
869
|
+
|
|
870
|
+
const moderate = results.violations.filter((v) => v.impact === 'moderate');
|
|
871
|
+
if (moderate.length > 0) {
|
|
872
|
+
console.warn(`Moderate on ${pagePath}:`, moderate.map((v) => v.id));
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### Playwright Project Configuration
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
// playwright.config.ts
|
|
882
|
+
import { defineConfig } from '@playwright/test';
|
|
883
|
+
|
|
884
|
+
export default defineConfig({
|
|
885
|
+
projects: [
|
|
886
|
+
{
|
|
887
|
+
name: 'accessibility',
|
|
888
|
+
testDir: './tests/accessibility',
|
|
889
|
+
use: { browserName: 'chromium', baseURL: 'http://localhost:3000' },
|
|
890
|
+
},
|
|
891
|
+
],
|
|
892
|
+
reporter: [
|
|
893
|
+
['html', { outputFolder: 'playwright-report' }],
|
|
894
|
+
['json', { outputFile: 'a11y-report.json' }],
|
|
895
|
+
],
|
|
896
|
+
});
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
---
|
|
900
|
+
|
|
901
|
+
## 8. Reusable A11y Fixture
|
|
902
|
+
|
|
903
|
+
```typescript
|
|
904
|
+
// fixtures/a11y.fixture.ts
|
|
905
|
+
import { test as base, expect } from '@playwright/test';
|
|
906
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
907
|
+
|
|
908
|
+
export const test = base.extend<{
|
|
909
|
+
a11y: {
|
|
910
|
+
assertNoViolations: (opts?: { tags?: string[]; exclude?: string[] }) => Promise<void>;
|
|
911
|
+
assertNoCritical: () => Promise<void>;
|
|
912
|
+
checkContrast: () => Promise<void>;
|
|
913
|
+
checkForms: () => Promise<void>;
|
|
914
|
+
};
|
|
915
|
+
}>({
|
|
916
|
+
a11y: async ({ page }, use) => {
|
|
917
|
+
await use({
|
|
918
|
+
assertNoViolations: async (opts) => {
|
|
919
|
+
let b = new AxeBuilder({ page }).withTags(opts?.tags ?? ['wcag2a', 'wcag2aa']);
|
|
920
|
+
for (const s of opts?.exclude ?? []) b = b.exclude(s);
|
|
921
|
+
expect((await b.analyze()).violations).toEqual([]);
|
|
922
|
+
},
|
|
923
|
+
assertNoCritical: async () => {
|
|
924
|
+
const r = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
|
925
|
+
expect(r.violations.filter((v) => v.impact === 'critical' || v.impact === 'serious')).toEqual([]);
|
|
926
|
+
},
|
|
927
|
+
checkContrast: async () => {
|
|
928
|
+
expect((await new AxeBuilder({ page }).withRules(['color-contrast']).analyze()).violations).toEqual([]);
|
|
929
|
+
},
|
|
930
|
+
checkForms: async () => {
|
|
931
|
+
expect((await new AxeBuilder({ page }).withRules(['label', 'select-name', 'autocomplete-valid']).analyze()).violations).toEqual([]);
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
export { expect } from '@playwright/test';
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
**Usage**:
|
|
941
|
+
|
|
942
|
+
```typescript
|
|
943
|
+
import { test } from '../fixtures/a11y.fixture';
|
|
944
|
+
|
|
945
|
+
test('homepage is accessible', async ({ page, a11y }) => {
|
|
946
|
+
await page.goto('/');
|
|
947
|
+
await a11y.assertNoViolations();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test('form inputs are labeled', async ({ page, a11y }) => {
|
|
951
|
+
await page.goto('/form');
|
|
952
|
+
await a11y.checkForms();
|
|
953
|
+
});
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
---
|
|
957
|
+
|
|
958
|
+
## 9. Quick Reference
|
|
959
|
+
|
|
960
|
+
### axe-core Rule Tags
|
|
961
|
+
|
|
962
|
+
| Tag | Meaning |
|
|
963
|
+
|---|---|
|
|
964
|
+
| `wcag2a` | WCAG 2.0 Level A |
|
|
965
|
+
| `wcag2aa` | WCAG 2.0 Level AA |
|
|
966
|
+
| `wcag2aaa` | WCAG 2.0 Level AAA |
|
|
967
|
+
| `wcag21a` | WCAG 2.1 Level A |
|
|
968
|
+
| `wcag21aa` | WCAG 2.1 Level AA |
|
|
969
|
+
| `wcag22aa` | WCAG 2.2 Level AA |
|
|
970
|
+
| `best-practice` | Non-WCAG best practices |
|
|
971
|
+
| `section508` | Section 508 requirements |
|
|
972
|
+
|
|
973
|
+
### Common axe-core Rules
|
|
974
|
+
|
|
975
|
+
| Rule | What It Checks |
|
|
976
|
+
|---|---|
|
|
977
|
+
| `color-contrast` | Text contrast ratio |
|
|
978
|
+
| `image-alt` | Images have alt text |
|
|
979
|
+
| `label` | Form inputs have labels |
|
|
980
|
+
| `button-name` | Buttons have accessible names |
|
|
981
|
+
| `link-name` | Links have accessible names |
|
|
982
|
+
| `html-has-lang` | HTML has lang attribute |
|
|
983
|
+
| `landmark-one-main` | Page has one main landmark |
|
|
984
|
+
| `region` | Content is in landmarks |
|
|
985
|
+
| `duplicate-id` | No duplicate IDs |
|
|
986
|
+
| `aria-roles` | Valid ARIA roles |
|
|
987
|
+
| `aria-valid-attr` | Valid ARIA attributes |
|
|
988
|
+
| `aria-hidden-focus` | No focusable elements inside aria-hidden |
|
|
989
|
+
| `tabindex` | tabindex values not greater than 0 |
|
|
990
|
+
| `bypass` | Page has skip nav mechanism |
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
## Related Skills
|
|
995
|
+
|
|
996
|
+
- `e2e-testing` - E2E testing with Playwright and visual regression
|
|
997
|
+
- `unit-testing` - Unit testing and TDD for component-level a11y
|
|
998
|
+
- `qa-engineer` - Overall test strategy including accessibility
|