diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c417e5..369b01d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - run: npm ci @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - run: npm ci @@ -54,10 +54,43 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - run: npm ci - name: Build ${{ matrix.app }} run: npm run build -w @bdc/${{ matrix.app }} + + - name: Upload build output + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.app }}-dist + path: apps/${{ matrix.app }}/dist + + a11y: + name: Accessibility + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + + - uses: actions/download-artifact@v4 + with: + name: site-dist + path: apps/site/dist + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + working-directory: apps/site + + - name: Accessibility audit + working-directory: apps/site + run: node a11y/full.js diff --git a/.gitignore b/.gitignore index 4fb464e..ee3279a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist .astro .env .env.* +test-results # apps/freshdesk Pipfile diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..25649a2 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.9.0 diff --git a/README.md b/README.md index a69f8fa..f0f8c49 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ Pull requests are automatically validated by CI, which runs: - **Lint**: Biome checks for code quality and formatting issues (results appear as inline annotations on the PR diff) - **Build**: the app is built to catch compilation errors - **Tests**: Vitest runs automated test suites to validate application behavior +- **Accessibility**: Playwright + axe-core audits every page against WCAG 2.0/2.1 AA (Section 508) All checks must pass before a PR can be merged. diff --git a/apps/site/a11y/README.md b/apps/site/a11y/README.md new file mode 100644 index 0000000..c328925 --- /dev/null +++ b/apps/site/a11y/README.md @@ -0,0 +1,70 @@ +# Accessibility Testing + +Automated accessibility checks using [Playwright](https://playwright.dev/) and +[axe-core](https://github.com/dequelabs/axe-core) via +[@axe-core/playwright](https://www.npmjs.com/package/@axe-core/playwright). + +## Compliance Target + +Our goal is [Section 508](https://www.section508.gov/) compliance. The revised Section 508 +standard (2017) incorporates [WCAG 2.0 Level AA](https://www.w3.org/TR/WCAG20/) by reference, +so meeting WCAG 2.0 AA satisfies 508 requirements. Our tests use axe's `wcag2a`, `wcag2aa`, +`wcag21a`, and `wcag21aa` tag set, covering WCAG 2.0 and 2.1 at Levels A and AA. + +## Usage + +### Single page (against a running dev server) + +```sh +npm run a11y:page -w @bdc/site -- http://localhost:4321/resources/faqs +``` + +Multiple URLs can be passed: + +```sh +npm run a11y:page -w @bdc/site -- http://localhost:4321/about http://localhost:4321/resources/costs +``` + +### Smoke test (builds, then tests key pages) + +```sh +npm run a11y:smoke -w @bdc/site +``` + +### Full site (builds, then tests every page in the sitemap) + +```sh +npm run a11y:full -w @bdc/site +``` + +## Shared Configuration + +All tests use the fixture in `axe-test.ts`, which configures axe-core with: + +- **WCAG tags**: `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` +- **Disabled rules**: `frame-tested` (axe cannot inject into cross-origin iframes like YouTube embeds) + +The Playwright config (`../playwright.config.ts`) includes a `webServer` entry that starts +`astro preview` on port 4321. With `reuseExistingServer: true`, it reuses an already-running +server (e.g., `astro dev`) when available. + +## Adding Exceptions + +If a specific element triggers a false positive, you can exclude it per-test: + +```ts +const results = await makeAxeBuilder() + .exclude('.some-selector') // exclude from all rules + .analyze(); +``` + +Or disable a specific rule: + +```ts +const results = await makeAxeBuilder() + .disableRules(['color-contrast']) + .analyze(); +``` + +See the [Playwright accessibility testing docs](https://playwright.dev/docs/accessibility-testing) +for more options including `include()`, `withRules()`, and snapshot-based approaches. diff --git a/apps/site/a11y/axe-test.ts b/apps/site/a11y/axe-test.ts new file mode 100644 index 0000000..54940c7 --- /dev/null +++ b/apps/site/a11y/axe-test.ts @@ -0,0 +1,25 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test as base, expect } from '@playwright/test'; + +type A11yFixtures = { + makeAxeBuilder: () => AxeBuilder; +}; + +/** + * Extended Playwright test with a shared AxeBuilder factory. + * + * Tags: WCAG 2.0 + 2.1 at Levels A and AA (Section 508 compliance). + * Disabled rules: + * - frame-tested: axe cannot inject into cross-origin iframes (e.g. YouTube embeds). + */ +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + await use(() => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .disableRules(['frame-tested']), + ); + }, +}); + +export { expect }; diff --git a/apps/site/a11y/full.js b/apps/site/a11y/full.js new file mode 100644 index 0000000..89bbc3d --- /dev/null +++ b/apps/site/a11y/full.js @@ -0,0 +1,27 @@ +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const sitemapPath = process.argv[2] ?? 'dist/sitemap-0.xml'; +const baseUrl = 'http://localhost:4321'; + +const xml = readFileSync(sitemapPath, 'utf-8'); +const urls = [...xml.matchAll(/(.*?)<\/loc>/g)].map((match) => { + const parsed = new URL(match[1]); + return `${baseUrl}${parsed.pathname}`; +}); + +if (urls.length === 0) { + console.error('No URLs found in sitemap'); + process.exit(1); +} + +console.log(`Testing ${urls.length} pages from sitemap…`); + +try { + execSync('npx playwright test a11y/page.test.ts', { + stdio: 'inherit', + env: { ...process.env, A11Y_URLS: urls.join(' ') }, + }); +} catch (error) { + process.exit(error.status ?? 1); +} diff --git a/apps/site/a11y/page.js b/apps/site/a11y/page.js new file mode 100644 index 0000000..8819a96 --- /dev/null +++ b/apps/site/a11y/page.js @@ -0,0 +1,17 @@ +import { execSync } from 'node:child_process'; + +const urls = process.argv.slice(2); + +if (urls.length === 0) { + console.error('Usage: npm run a11y:page -- [url...]'); + process.exit(1); +} + +try { + execSync('npx playwright test a11y/page.test.ts', { + stdio: 'inherit', + env: { ...process.env, A11Y_URLS: urls.join(' ') }, + }); +} catch (error) { + process.exit(error.status ?? 1); +} diff --git a/apps/site/a11y/page.test.ts b/apps/site/a11y/page.test.ts new file mode 100644 index 0000000..4b14b3e --- /dev/null +++ b/apps/site/a11y/page.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from './axe-test'; + +const urls = (process.env.A11Y_URLS ?? '').split(/\s+/).filter(Boolean); + +test.describe('page accessibility', () => { + if (urls.length === 0) { + test('requires URLs', () => { + throw new Error( + 'No URLs to test. Pass one or more URLs as arguments:\n' + + ' npm run a11y:page -w @bdc/site -- http://localhost:4321/about/overview', + ); + }); + return; + } + + for (const url of urls) { + test(url, async ({ page, makeAxeBuilder }) => { + await page.goto(url); + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + } +}); diff --git a/apps/site/a11y/smoke.test.ts b/apps/site/a11y/smoke.test.ts new file mode 100644 index 0000000..a0ee49b --- /dev/null +++ b/apps/site/a11y/smoke.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from './axe-test'; + +const paths = ['/', '/resources/costs', '/about/overview']; + +for (const path of paths) { + test(path, async ({ page, makeAxeBuilder }) => { + await page.goto(path); + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); +} diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index 998597c..5551f4a 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -2,16 +2,20 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import mdx from '@astrojs/mdx'; import react from '@astrojs/react'; +import sitemap from '@astrojs/sitemap'; import { defineConfig } from 'astro/config'; import favicons from 'astro-favicons'; import { loadEnv } from 'vite'; +const siteUrl = process.env.SITE_URL || 'https://biodatacatalyst.nhlbi.nih.gov'; + const rootDir = dirname(fileURLToPath(import.meta.url)); Object.assign(process.env, loadEnv('', rootDir, '')); const uswdsPackages = join(rootDir, '../../node_modules/@uswds/uswds/packages'); export default defineConfig({ - integrations: [mdx(), react(), favicons()], + site: siteUrl, + integrations: [mdx(), react(), sitemap(), favicons()], markdown: { remarkPlugins: [['remark-excerpt', { remove: true }]], }, diff --git a/apps/site/package.json b/apps/site/package.json index 5891635..b4d1379 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -7,6 +7,9 @@ "dev": "astro dev", "build": "astro build && pagefind --site dist", "preview": "astro preview", + "a11y:page": "node a11y/page.js", + "a11y:smoke": "astro build && playwright test a11y/smoke.test.ts", + "a11y:full": "astro build && node a11y/full.js", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui" @@ -14,18 +17,21 @@ "dependencies": { "@astrojs/mdx": "^4.3.13", "@astrojs/react": "^4.2.0", + "@astrojs/sitemap": "^3.7.0", "@bdc/uswds-theme": "*", "@trussworks/react-uswds": "^11.0.0", "@uswds/uswds": "^3.13.0", "astro": "^5.17.0", "astro-favicons": "^3.1.5", "focus-trap-react": "^11.0.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", "remark-excerpt": "^1.0.0-beta.1", "sass-embedded": "^1.83.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.1", + "@playwright/test": "^1.52.0", "pagefind": "^1.4.0" } } diff --git a/apps/site/playwright.config.ts b/apps/site/playwright.config.ts new file mode 100644 index 0000000..c8728d0 --- /dev/null +++ b/apps/site/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './a11y', + timeout: 30_000, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:4321', + }, + webServer: { + command: 'npx astro preview --port 4321', + port: 4321, + reuseExistingServer: true, + }, +}); diff --git a/apps/site/src/components/Breadcrumbs.astro b/apps/site/src/components/Breadcrumbs.astro index 059f9f0..5859be4 100644 --- a/apps/site/src/components/Breadcrumbs.astro +++ b/apps/site/src/components/Breadcrumbs.astro @@ -13,7 +13,7 @@ const { items } = Astro.props as { items: Crumb[] };