Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b96ed65
align gitignore
mbwatson Feb 19, 2026
174e7d8
install, setup @astro/sitemap
mbwatson Feb 19, 2026
3907093
setup pa11y, script, and worflow step
mbwatson Feb 23, 2026
a686fc5
check script on some a11y fixes
mbwatson Feb 23, 2026
a3205b1
fix/tweak pa11y setup; add page test script, config
mbwatson Feb 23, 2026
f42d172
lint
mbwatson Feb 23, 2026
4c56de5
address minimatch, globby dep vulnerability issue
mbwatson Feb 23, 2026
1924090
update workflow w relocated files
mbwatson Feb 23, 2026
3a91c29
use start-server-and-test for all a11y test scripts and ci workflow
mbwatson Feb 23, 2026
f532220
skip a11y iframe scan (currently only affects yt embed)
mbwatson Feb 24, 2026
8cb2fe6
fix news, event links
mbwatson Feb 24, 2026
3e058dc
a11y fixes--news, event templates & components
mbwatson Feb 24, 2026
cce1423
minor ehading fixes, note about a11y target in readme
mbwatson Feb 24, 2026
00e7162
init playwright switch
mbwatson Feb 24, 2026
ed3f1f3
lint
mbwatson Feb 24, 2026
2615bdf
darken video embed caption text
mbwatson Feb 24, 2026
a60a408
add playwright-generated test results to .gitignore
mbwatson Feb 24, 2026
536e6b0
Merge branch 'main' into feature/ci-a11y-check
mbwatson Feb 27, 2026
69b37f0
Merge branch 'main' into feature/ci-a11y-check
mbwatson Mar 3, 2026
f3a73d0
add a11y testing supporting documentation
mbwatson Mar 4, 2026
38a4822
Update package-lock.json with optionalDependencies for sharp
suejinkim20 Mar 4, 2026
b8c6aa8
Revert "Update package-lock.json with optionalDependencies for sharp"
mbwatson Mar 4, 2026
015bcaa
re-install
mbwatson Mar 4, 2026
0679615
use React 19 across apps
mbwatson Mar 4, 2026
bead295
ensure devs use same node, npm versions
mbwatson Mar 4, 2026
42d6b1a
specify dev node, npm versions
mbwatson Mar 4, 2026
534c906
align ci node version with dev version defined in .nvmrc
mbwatson Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22
node-version-file: .nvmrc
cache: npm

- run: npm ci
Expand All @@ -35,7 +35,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22
node-version-file: .nvmrc
cache: npm

- run: npm ci
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist
.astro
.env
.env.*
test-results

# apps/freshdesk
Pipfile
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.9.0
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
70 changes: 70 additions & 0 deletions apps/site/a11y/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions apps/site/a11y/axe-test.ts
Original file line number Diff line number Diff line change
@@ -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<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['frame-tested']),
);
},
});

export { expect };
27 changes: 27 additions & 0 deletions apps/site/a11y/full.js
Original file line number Diff line number Diff line change
@@ -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>(.*?)<\/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);
}
17 changes: 17 additions & 0 deletions apps/site/a11y/page.js
Original file line number Diff line number Diff line change
@@ -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> [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);
}
23 changes: 23 additions & 0 deletions apps/site/a11y/page.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
}
});
11 changes: 11 additions & 0 deletions apps/site/a11y/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
}
6 changes: 5 additions & 1 deletion apps/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]],
},
Expand Down
10 changes: 8 additions & 2 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@
"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"
},
"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"
}
}
15 changes: 15 additions & 0 deletions apps/site/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
4 changes: 2 additions & 2 deletions apps/site/src/components/Breadcrumbs.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { items } = Astro.props as { items: Crumb[] };
<nav class="usa-breadcrumb" aria-label="Breadcrumbs">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<Link to="/" class="usa-breadcrumb__link">Home</Link>
<Link to="/" class="usa-breadcrumb__link text-underline">Home</Link>
</li>
{items.map((item, i) => {
const isLast = i === items.length - 1;
Expand All @@ -22,7 +22,7 @@ const { items } = Astro.props as { items: Crumb[] };
{isLast ? (
<span aria-current="page">{item.label}</span>
) : (
<Link to={item.href} class="usa-breadcrumb__link">{item.label}</Link>
<Link to={item.href} class="usa-breadcrumb__link text-underline">{item.label}</Link>
)}
</li>
);
Expand Down
8 changes: 1 addition & 7 deletions apps/site/src/components/events/EventHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const endDateStr = endDate?.toLocaleDateString('en-US', dateOptions);

<h1>{title}</h1>

<div class="event__meta border-bottom padding-bottom-1 margin-bottom-2">
<div class="event__meta border-bottom padding-bottom-1 margin-bottom-2 text-base-dark">

<div class="display-flex flex-align-center gap-1 margin-bottom-05">
<Icon name="event" size={3} class="margin-right-1"/>
Expand Down Expand Up @@ -47,9 +47,3 @@ const endDateStr = endDate?.toLocaleDateString('en-US', dateOptions);
</div>

</header>

<style>
.event__meta {
color: gray;
}
</style>
2 changes: 1 addition & 1 deletion apps/site/src/components/events/EventsList.astro
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const dateOptions = {
<div class="usa-collection__body">

<h4 class="usa-collection__heading">
<a href={`/events/${event.id}/`} class="usa-link">
<a href={`/updates/events/${event.id}/`} class="usa-link">
{event.data.title}
</a>
</h4>
Expand Down
6 changes: 3 additions & 3 deletions apps/site/src/components/faqs/FaqList.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ const { items } = Astro.props;
<div class="usa-accordion usa-accordion--bordered" data-allow-multiple>
{items.map(faq => (
<>
<h4 class="usa-accordion__heading">
<h2 class="usa-accordion__heading">
<button
type="button"
class="usa-accordion__button"
class="usa-accordion__button bg-base-lightest text-base-darkest"
aria-expanded="false"
aria-controls={`faq-${faq.id}`}
>
{faq.data.title}
</button>
</h4>
</h2>
<div id={`faq-${faq.id}`} class="usa-accordion__content usa-prose" hidden>
<Fragment set:html={faq.data.description} />
</div>
Expand Down
Loading
Loading