Skip to content

Commit 8a6988b

Browse files
authored
Add accessibility testing to CI (#8)
1 parent d72f443 commit 8a6988b

28 files changed

Lines changed: 5892 additions & 2863 deletions

.github/workflows/ci.yaml

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919

2020
- uses: actions/setup-node@v4
2121
with:
22-
node-version: 22
22+
node-version-file: .nvmrc
2323
cache: npm
2424

2525
- run: npm ci
@@ -35,7 +35,7 @@ jobs:
3535

3636
- uses: actions/setup-node@v4
3737
with:
38-
node-version: 22
38+
node-version-file: .nvmrc
3939
cache: npm
4040

4141
- run: npm ci
@@ -54,10 +54,43 @@ jobs:
5454

5555
- uses: actions/setup-node@v4
5656
with:
57-
node-version: 22
57+
node-version-file: .nvmrc
5858
cache: npm
5959

6060
- run: npm ci
6161

6262
- name: Build ${{ matrix.app }}
6363
run: npm run build -w @bdc/${{ matrix.app }}
64+
65+
- name: Upload build output
66+
uses: actions/upload-artifact@v4
67+
with:
68+
name: ${{ matrix.app }}-dist
69+
path: apps/${{ matrix.app }}/dist
70+
71+
a11y:
72+
name: Accessibility
73+
runs-on: ubuntu-latest
74+
needs: build
75+
steps:
76+
- uses: actions/checkout@v4
77+
78+
- uses: actions/setup-node@v4
79+
with:
80+
node-version-file: .nvmrc
81+
cache: npm
82+
83+
- run: npm ci
84+
85+
- uses: actions/download-artifact@v4
86+
with:
87+
name: site-dist
88+
path: apps/site/dist
89+
90+
- name: Install Playwright browsers
91+
run: npx playwright install chromium --with-deps
92+
working-directory: apps/site
93+
94+
- name: Accessibility audit
95+
working-directory: apps/site
96+
run: node a11y/full.js

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ dist
44
.astro
55
.env
66
.env.*
7+
test-results
78

89
# apps/freshdesk
910
Pipfile

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24.9.0

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Pull requests are automatically validated by CI, which runs:
137137
- **Lint**: Biome checks for code quality and formatting issues (results appear as inline annotations on the PR diff)
138138
- **Build**: the app is built to catch compilation errors
139139
- **Tests**: Vitest runs automated test suites to validate application behavior
140+
- **Accessibility**: Playwright + axe-core audits every page against WCAG 2.0/2.1 AA (Section 508)
140141

141142
All checks must pass before a PR can be merged.
142143

apps/site/a11y/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Accessibility Testing
2+
3+
Automated accessibility checks using [Playwright](https://playwright.dev/) and
4+
[axe-core](https://github.com/dequelabs/axe-core) via
5+
[@axe-core/playwright](https://www.npmjs.com/package/@axe-core/playwright).
6+
7+
## Compliance Target
8+
9+
Our goal is [Section 508](https://www.section508.gov/) compliance. The revised Section 508
10+
standard (2017) incorporates [WCAG 2.0 Level AA](https://www.w3.org/TR/WCAG20/) by reference,
11+
so meeting WCAG 2.0 AA satisfies 508 requirements. Our tests use axe's `wcag2a`, `wcag2aa`,
12+
`wcag21a`, and `wcag21aa` tag set, covering WCAG 2.0 and 2.1 at Levels A and AA.
13+
14+
## Usage
15+
16+
### Single page (against a running dev server)
17+
18+
```sh
19+
npm run a11y:page -w @bdc/site -- http://localhost:4321/resources/faqs
20+
```
21+
22+
Multiple URLs can be passed:
23+
24+
```sh
25+
npm run a11y:page -w @bdc/site -- http://localhost:4321/about http://localhost:4321/resources/costs
26+
```
27+
28+
### Smoke test (builds, then tests key pages)
29+
30+
```sh
31+
npm run a11y:smoke -w @bdc/site
32+
```
33+
34+
### Full site (builds, then tests every page in the sitemap)
35+
36+
```sh
37+
npm run a11y:full -w @bdc/site
38+
```
39+
40+
## Shared Configuration
41+
42+
All tests use the fixture in `axe-test.ts`, which configures axe-core with:
43+
44+
- **WCAG tags**: `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`
45+
- **Disabled rules**: `frame-tested` (axe cannot inject into cross-origin iframes like YouTube embeds)
46+
47+
The Playwright config (`../playwright.config.ts`) includes a `webServer` entry that starts
48+
`astro preview` on port 4321. With `reuseExistingServer: true`, it reuses an already-running
49+
server (e.g., `astro dev`) when available.
50+
51+
## Adding Exceptions
52+
53+
If a specific element triggers a false positive, you can exclude it per-test:
54+
55+
```ts
56+
const results = await makeAxeBuilder()
57+
.exclude('.some-selector') // exclude from all rules
58+
.analyze();
59+
```
60+
61+
Or disable a specific rule:
62+
63+
```ts
64+
const results = await makeAxeBuilder()
65+
.disableRules(['color-contrast'])
66+
.analyze();
67+
```
68+
69+
See the [Playwright accessibility testing docs](https://playwright.dev/docs/accessibility-testing)
70+
for more options including `include()`, `withRules()`, and snapshot-based approaches.

apps/site/a11y/axe-test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { test as base, expect } from '@playwright/test';
3+
4+
type A11yFixtures = {
5+
makeAxeBuilder: () => AxeBuilder;
6+
};
7+
8+
/**
9+
* Extended Playwright test with a shared AxeBuilder factory.
10+
*
11+
* Tags: WCAG 2.0 + 2.1 at Levels A and AA (Section 508 compliance).
12+
* Disabled rules:
13+
* - frame-tested: axe cannot inject into cross-origin iframes (e.g. YouTube embeds).
14+
*/
15+
export const test = base.extend<A11yFixtures>({
16+
makeAxeBuilder: async ({ page }, use) => {
17+
await use(() =>
18+
new AxeBuilder({ page })
19+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
20+
.disableRules(['frame-tested']),
21+
);
22+
},
23+
});
24+
25+
export { expect };

apps/site/a11y/full.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { execSync } from 'node:child_process';
2+
import { readFileSync } from 'node:fs';
3+
4+
const sitemapPath = process.argv[2] ?? 'dist/sitemap-0.xml';
5+
const baseUrl = 'http://localhost:4321';
6+
7+
const xml = readFileSync(sitemapPath, 'utf-8');
8+
const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map((match) => {
9+
const parsed = new URL(match[1]);
10+
return `${baseUrl}${parsed.pathname}`;
11+
});
12+
13+
if (urls.length === 0) {
14+
console.error('No URLs found in sitemap');
15+
process.exit(1);
16+
}
17+
18+
console.log(`Testing ${urls.length} pages from sitemap…`);
19+
20+
try {
21+
execSync('npx playwright test a11y/page.test.ts', {
22+
stdio: 'inherit',
23+
env: { ...process.env, A11Y_URLS: urls.join(' ') },
24+
});
25+
} catch (error) {
26+
process.exit(error.status ?? 1);
27+
}

apps/site/a11y/page.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { execSync } from 'node:child_process';
2+
3+
const urls = process.argv.slice(2);
4+
5+
if (urls.length === 0) {
6+
console.error('Usage: npm run a11y:page -- <url> [url...]');
7+
process.exit(1);
8+
}
9+
10+
try {
11+
execSync('npx playwright test a11y/page.test.ts', {
12+
stdio: 'inherit',
13+
env: { ...process.env, A11Y_URLS: urls.join(' ') },
14+
});
15+
} catch (error) {
16+
process.exit(error.status ?? 1);
17+
}

apps/site/a11y/page.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect, test } from './axe-test';
2+
3+
const urls = (process.env.A11Y_URLS ?? '').split(/\s+/).filter(Boolean);
4+
5+
test.describe('page accessibility', () => {
6+
if (urls.length === 0) {
7+
test('requires URLs', () => {
8+
throw new Error(
9+
'No URLs to test. Pass one or more URLs as arguments:\n' +
10+
' npm run a11y:page -w @bdc/site -- http://localhost:4321/about/overview',
11+
);
12+
});
13+
return;
14+
}
15+
16+
for (const url of urls) {
17+
test(url, async ({ page, makeAxeBuilder }) => {
18+
await page.goto(url);
19+
const results = await makeAxeBuilder().analyze();
20+
expect(results.violations).toEqual([]);
21+
});
22+
}
23+
});

apps/site/a11y/smoke.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, test } from './axe-test';
2+
3+
const paths = ['/', '/resources/costs', '/about/overview'];
4+
5+
for (const path of paths) {
6+
test(path, async ({ page, makeAxeBuilder }) => {
7+
await page.goto(path);
8+
const results = await makeAxeBuilder().analyze();
9+
expect(results.violations).toEqual([]);
10+
});
11+
}

0 commit comments

Comments
 (0)