diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml new file mode 100644 index 0000000..d362119 --- /dev/null +++ b/.github/workflows/accessibility.yml @@ -0,0 +1,59 @@ +name: Accessibility + +on: + pull_request: + branches: + - main + - next + paths: + - ".github/workflows/accessibility.yml" + - "astro.config.*" + - "package.json" + - "playwright.config.*" + - "pnpm-lock.yaml" + - "public/**" + - "src/**" + - "tests/**" + - "tsconfig.json" + push: + branches: + - main + - next + paths: + - ".github/workflows/accessibility.yml" + - "astro.config.*" + - "package.json" + - "playwright.config.*" + - "pnpm-lock.yaml" + - "public/**" + - "src/**" + - "tests/**" + - "tsconfig.json" + workflow_dispatch: +permissions: + contents: read +env: + FORCE_COLOR: 1 + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set Up PNPM + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Set Up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Run Accessibility Audit + run: pnpm test:ci diff --git a/.gitignore b/.gitignore index 16d54bb..308dca4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # build output dist/ +playwright-report/ +test-results/ # generated types .astro/ diff --git a/package.json b/package.json index 70f9ad8..c0daa7b 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,22 @@ "prebuild": "node ./scripts/sync-governance.mjs", "build": "astro build", "preview": "astro preview", + "preview:test": "pnpm run build && astro preview --port 8787", "sync:governance": "node ./scripts/sync-governance.mjs", "astro": "astro", "upgrade:astro": "pnpm dlx @astrojs/upgrade --all", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "playwright install --with-deps chromium && playwright test", + "test:ci": "playwright test" }, "dependencies": { "astro": "^6.1.9" }, "devDependencies": { "@astrojs/upgrade": "^0.7.2", + "@axe-core/playwright": "^4.12.1", + "@playwright/test": "^1.62.0", + "@types/node": "^26.1.1", "prettier": "^3.8.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..26e8a94 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +const isCI = !!process.env["CI"]; + +export default defineConfig({ + forbidOnly: isCI, + fullyParallel: true, + projects: [ + { + name: "Chrome", + use: { + ...devices["Desktop Chrome"], + channel: isCI ? "chrome" : undefined, + headless: true, + }, + }, + { + name: "Mobile Chrome", + use: { + ...devices["Pixel 5"], + channel: isCI ? "chrome" : undefined, + headless: true, + }, + }, + ], + reporter: "list", + testDir: "./tests", + testMatch: /.*\.test\.ts/, + // The timeout for the accessibility tests only. + timeout: 180 * 1_000, + webServer: [ + { + command: "pnpm run preview:test", + reuseExistingServer: !isCI, + // The timeout of the single build step ran before the accessibility tests. + timeout: 120 * 1_000, + url: "http://localhost:8787", + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 299d6ab..c177c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,20 @@ importers: dependencies: astro: specifier: ^6.1.9 - version: 6.4.8(rollup@4.62.2) + version: 6.4.8(@types/node@26.1.1)(rollup@4.62.2) devDependencies: "@astrojs/upgrade": specifier: ^0.7.2 version: 0.7.3 + "@axe-core/playwright": + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.62.0) + "@playwright/test": + specifier: ^1.62.0 + version: 1.62.0 + "@types/node": + specifier: ^26.1.1 + version: 26.1.1 prettier: specifier: ^3.8.0 version: 3.9.6 @@ -66,6 +75,14 @@ packages: engines: { node: ">=22.12.0" } hasBin: true + "@axe-core/playwright@4.12.1": + resolution: + { + integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==, + } + peerDependencies: + playwright-core: ">= 1.0.0" + "@babel/helper-string-parser@7.29.7": resolution: { @@ -580,6 +597,14 @@ packages: integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==, } + "@playwright/test@1.62.0": + resolution: + { + integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==, + } + engines: { node: ">=20" } + hasBin: true + "@rollup/pluginutils@5.4.0": resolution: { @@ -883,6 +908,12 @@ packages: integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==, } + "@types/node@26.1.1": + resolution: + { + integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==, + } + "@types/unist@3.0.3": resolution: { @@ -957,6 +988,13 @@ packages: engines: { node: ">=22.12.0", npm: ">=9.6.5", pnpm: ">=7.1.0" } hasBin: true + axe-core@4.12.1: + resolution: + { + integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==, + } + engines: { node: ">=4" } + axobject-query@4.1.0: resolution: { @@ -1325,6 +1363,14 @@ packages: } engines: { node: ">=20" } + fsevents@2.3.2: + resolution: + { + integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + fsevents@2.3.3: resolution: { @@ -1966,6 +2012,22 @@ packages: } engines: { node: ">=12" } + playwright-core@1.62.0: + resolution: + { + integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==, + } + engines: { node: ">=20" } + hasBin: true + + playwright@1.62.0: + resolution: + { + integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==, + } + engines: { node: ">=20" } + hasBin: true + postcss@8.5.23: resolution: { @@ -2312,6 +2374,12 @@ packages: integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==, } + undici-types@8.3.0: + resolution: + { + integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==, + } + unified@11.0.5: resolution: { @@ -2628,6 +2696,11 @@ snapshots: semver: 7.8.5 terminal-link: 5.0.0 + "@axe-core/playwright@4.12.1(playwright-core@1.62.0)": + dependencies: + axe-core: 4.12.1 + playwright-core: 1.62.0 + "@babel/helper-string-parser@7.29.7": {} "@babel/helper-validator-identifier@7.29.7": {} @@ -2841,6 +2914,10 @@ snapshots: "@oslojs/encoding@1.1.0": {} + "@playwright/test@1.62.0": + dependencies: + playwright: 1.62.0 + "@rollup/pluginutils@5.4.0(rollup@4.62.2)": dependencies: "@types/estree": 1.0.9 @@ -2984,6 +3061,10 @@ snapshots: dependencies: "@types/unist": 3.0.3 + "@types/node@26.1.1": + dependencies: + undici-types: 8.3.0 + "@types/unist@3.0.3": {} "@ungap/structured-clone@1.3.3": {} @@ -3011,7 +3092,7 @@ snapshots: array-iterate@2.0.1: {} - astro@6.4.8(rollup@4.62.2): + astro@6.4.8(@types/node@26.1.1)(rollup@4.62.2): dependencies: "@astrojs/compiler": 4.0.0 "@astrojs/internal-helpers": 0.10.0 @@ -3063,8 +3144,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: 7.3.6 - vitefu: 1.1.3(vite@7.3.6) + vite: 7.3.6(@types/node@26.1.1) + vitefu: 1.1.3(vite@7.3.6(@types/node@26.1.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -3104,6 +3185,8 @@ snapshots: - uploadthing - yaml + axe-core@4.12.1: {} + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -3288,6 +3371,9 @@ snapshots: dependencies: tiny-inflate: 1.0.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3847,6 +3933,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.23: dependencies: nanoid: 3.3.16 @@ -4127,6 +4221,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@8.3.0: {} + unified@11.0.5: dependencies: "@types/unist": 3.0.3 @@ -4211,7 +4307,7 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.3 - vite@7.3.6: + vite@7.3.6(@types/node@26.1.1): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.5) @@ -4220,11 +4316,12 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + "@types/node": 26.1.1 fsevents: 2.3.3 - vitefu@1.1.3(vite@7.3.6): + vitefu@1.1.3(vite@7.3.6(@types/node@26.1.1)): optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@26.1.1) web-namespaces@2.0.1: {} diff --git a/src/styles/global.css b/src/styles/global.css index 7bc4a95..df29992 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3,7 +3,7 @@ --bg-rgb: 250 250 250; --text: #111827; --text-muted: #4b5563; - --accent: #009199; + --accent: #007a80; --accent-glow: rgb(124 58 237 / 0.2); --accent-soft: rgb(124 58 237 / 0.08); --card-bg: #ffffff; @@ -36,6 +36,7 @@ --bg-rgb: 11 11 18; --text: #f8fafc; --text-muted: #94a3b8; + --accent: #00d5dd; --accent-glow: rgb(0 242 255 / 0.25); --accent-soft: rgb(0 242 255 / 0.08); --card-bg: #13131c; @@ -398,6 +399,16 @@ code { margin-bottom: 0; } +.highlight-box a { + color: #006d72; +} + +@media (prefers-color-scheme: dark) { + .highlight-box a { + color: #00d5dd; + } +} + .secondary-callout { margin-top: var(--space-4); } @@ -541,8 +552,6 @@ code { width: 100%; border-collapse: collapse; margin-block: var(--space-4); - display: block; - overflow-x: auto; font-size: 0.95rem; border-radius: var(--radius-md); } diff --git a/tests/accessibility.test.ts b/tests/accessibility.test.ts new file mode 100644 index 0000000..09f9550 --- /dev/null +++ b/tests/accessibility.test.ts @@ -0,0 +1,75 @@ +import { styleText } from "node:util"; +import { expect, localURL, siteRoutes, test } from "./accessibility"; + +function formatImpact(impact: string | null | undefined) { + if (!impact) { + return "unknown"; + } + + const impactKey: Record = { + minor: styleText("blue", impact), + moderate: styleText("yellowBright", impact), + serious: styleText("yellow", impact), + critical: styleText("red", impact), + }; + + return impactKey[impact] ?? impact; +} + +const modes = ["dark", "light"] as const; + +for (const mode of modes) { + for (const pathname of siteRoutes) { + const pageURL = `${localURL}${pathname}`; + const pageLabel = pathname === "/" ? "homepage" : pathname; + + test(`Testing for accessibility violations on ${pageLabel} in ${mode} mode.`, async ({ + page, + makeAxeBuilder, + }) => { + await page.emulateMedia({ colorScheme: mode }); + + const response = await page.goto(pageURL, { waitUntil: "networkidle" }); + expect( + response, + `Expected ${pageURL} to load successfully in ${mode} mode.`, + ).not.toBeNull(); + expect( + response?.ok(), + `Expected ${pageURL} to return a successful response in ${mode} mode.`, + ).toBeTruthy(); + + const { violations } = await makeAxeBuilder().analyze(); + const reportMessage = `Found ${violations.length} accessibility violations on ${pageLabel} in ${mode} mode.`; + + if (violations.length === 0) { + expect(violations, reportMessage).toHaveLength(0); + return; + } + + const violationLog = violations + .map((violation, violationIndex) => { + const nodes = violation.nodes + .map( + (node, nodeIndex) => ` +${styleText("redBright", ` Node ${nodeIndex + 1} HTML:`)} ${node.html} +${styleText("redBright", ` Node ${nodeIndex + 1} CSS:`)} ${node.target.join(", ")} +${styleText("green", " Suggested fix:")} + ${node.failureSummary ?? "No failure summary provided."}`, + ) + .join("\n"); + + return ` +${styleText(["redBright", "bold"], `Violation ${violationIndex + 1}:`)} +${styleText("redBright", " Violation ID:")} ${violation.id} +${styleText("redBright", " Violation Impact:")} ${formatImpact(violation.impact)} +${styleText("redBright", " Violation Description:")} ${violation.help} +${styleText("redBright", " More info:")} ${violation.helpUrl} +${nodes}`; + }) + .join("\n\n"); + + throw new Error(`${violationLog}\\n\\n${reportMessage}`); + }); + } +} diff --git a/tests/accessibility.ts b/tests/accessibility.ts new file mode 100644 index 0000000..c452b59 --- /dev/null +++ b/tests/accessibility.ts @@ -0,0 +1,29 @@ +import AxeBuilder from "@axe-core/playwright"; +import { test as base } from "@playwright/test"; + +type AxeFixture = { + makeAxeBuilder: () => AxeBuilder; +}; + +export const localURL = "http://localhost:8787"; + +// Keep this list explicit because the site does not generate a sitemap. +export const siteRoutes = ["/", "/governance/"] as const; + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }).withTags([ + "wcag2a", + "wcag21a", + "wcag2aa", + "wcag21aa", + "wcag22aa", + "best-practice", + ]); + + await use(makeAxeBuilder); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/tsconfig.json b/tsconfig.json index 8bf91d3..328adb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], + "compilerOptions": { + "types": ["node"] + }, "exclude": ["dist"] }