S39: a11y tooling — pa11y-ci config + axe-core Puppeteer runner
- SHA
248b4bfaf4a882d5945a526eb07b5fa4e9634741- Parents
-
0dcdf42 - Tree
66d67af
248b4bf
248b4bfaf4a882d5945a526eb07b5fa4e96347410dcdf42
66d67af| Status | File | + | - |
|---|---|---|---|
| A |
tests/a11y/README.md
|
76 | 0 |
| A |
tests/a11y/axe-runner.js
|
115 | 0 |
| A |
tests/a11y/pa11y-config.json
|
30 | 0 |
tests/a11y/README.mdadded@@ -0,0 +1,76 @@ | |||
| 1 | +# Accessibility audit | ||
| 2 | + | ||
| 3 | +This directory contains the tooling for the WCAG AA audit pass | ||
| 4 | +described in S39. Two complementary tools: | ||
| 5 | + | ||
| 6 | +- **pa11y-ci** — broad scan across anonymous routes. Runs from a | ||
| 7 | + static URL list in `pa11y-config.json`. Cheap to run, good for | ||
| 8 | + catching regressions on the main pages. | ||
| 9 | +- **axe-core via Puppeteer** — focused scan on the routes that | ||
| 10 | + need a logged-in session (settings, dashboard, new-repo form, | ||
| 11 | + notifications). Drives a headless Chromium through a real | ||
| 12 | + sign-in. | ||
| 13 | + | ||
| 14 | +Automated tools catch ~30% of accessibility issues. Pair these | ||
| 15 | +with manual screen-reader passes (NVDA on Windows / VoiceOver on | ||
| 16 | +macOS) and keyboard-only navigation. | ||
| 17 | + | ||
| 18 | +## Prerequisites | ||
| 19 | + | ||
| 20 | +```sh | ||
| 21 | +# One-time, in your CI runner or workstation: | ||
| 22 | +npm i -g pa11y-ci puppeteer @axe-core/puppeteer | ||
| 23 | +``` | ||
| 24 | + | ||
| 25 | +You also need a running shithub instance with seeded data. For | ||
| 26 | +local runs: | ||
| 27 | + | ||
| 28 | +```sh | ||
| 29 | +make dev-db dev-storage dev-migrate dev | ||
| 30 | +# in another terminal, sign up an account so the auth flow has | ||
| 31 | +# a known credential. Then export it: | ||
| 32 | +export SHITHUB_USER=alice | ||
| 33 | +export SHITHUB_PASS=<the-password-you-set> | ||
| 34 | +export SHITHUB_URL=http://127.0.0.1:8080 | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +## Running | ||
| 38 | + | ||
| 39 | +```sh | ||
| 40 | +# Anonymous routes: | ||
| 41 | +make audit-a11y-pa11y | ||
| 42 | + | ||
| 43 | +# Authenticated + diff/issue views: | ||
| 44 | +make audit-a11y-axe | ||
| 45 | + | ||
| 46 | +# Both: | ||
| 47 | +make audit-a11y | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +Both tools exit non-zero on a high-severity (critical / serious) | ||
| 51 | +violation. CI gates on a clean run; lower-severity findings go to | ||
| 52 | +the audit record (`docs/internal/a11y-audit-record.md`). | ||
| 53 | + | ||
| 54 | +## What's covered | ||
| 55 | + | ||
| 56 | +| Tool | Scope | | ||
| 57 | +|--------------|------------------------------------------------------| | ||
| 58 | +| pa11y-ci | `/`, `/signup`, `/login`, `/explore`, `/-/health` | | ||
| 59 | +| axe-runner | dashboard, settings/profile, settings/security/2fa, `/new`, `/notifications` | | ||
| 60 | +| Manual SR | every primary form + diff view + admin surfaces | | ||
| 61 | + | ||
| 62 | +## What's NOT covered by automation | ||
| 63 | + | ||
| 64 | +- **Screen-reader semantics** that pass programmatic checks but | ||
| 65 | + read poorly. Diff view labelling old/new for SR users is the | ||
| 66 | + canonical example — `aria-label` checks pass; whether the | ||
| 67 | + resulting verbal output makes sense needs a human. | ||
| 68 | +- **Keyboard order** that diverges from visual order in | ||
| 69 | + CSS-positioned layouts. `tabindex` audits help but don't catch | ||
| 70 | + every case. | ||
| 71 | +- **Modal focus trapping** under unusual interaction sequences. | ||
| 72 | +- **Color contrast** in user-content (avatars, custom topic colors). | ||
| 73 | + | ||
| 74 | +Findings from the manual audits are recorded in | ||
| 75 | +`docs/internal/a11y-audit-record.md` along with the dispositions | ||
| 76 | +(fixed / accepted-with-rationale). | ||
tests/a11y/axe-runner.jsadded@@ -0,0 +1,115 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | +// | ||
| 3 | +// axe-core runner. Drives Puppeteer through the authenticated and | ||
| 4 | +// anonymous routes that matter for WCAG AA compliance, runs axe on | ||
| 5 | +// each, and prints a structured report. Exits non-zero on any | ||
| 6 | +// "critical" or "serious" violation. | ||
| 7 | +// | ||
| 8 | +// Usage: | ||
| 9 | +// SHITHUB_URL=http://127.0.0.1:8080 \ | ||
| 10 | +// SHITHUB_USER=alice SHITHUB_PASS=... \ | ||
| 11 | +// node tests/a11y/axe-runner.js | ||
| 12 | +// | ||
| 13 | +// Dependencies (install once in CI or your dev machine): | ||
| 14 | +// npm i puppeteer @axe-core/puppeteer | ||
| 15 | +// | ||
| 16 | +// The runner is intentionally narrow — pa11y-ci handles broad | ||
| 17 | +// scanning; this script focuses on flows that need a logged-in | ||
| 18 | +// session (settings, /admin, repo settings) plus the diff view, | ||
| 19 | +// which has subtle semantics for screen readers (old/new sides). | ||
| 20 | + | ||
| 21 | +const { AxePuppeteer } = require("@axe-core/puppeteer"); | ||
| 22 | +const puppeteer = require("puppeteer"); | ||
| 23 | + | ||
| 24 | +const BASE = process.env.SHITHUB_URL || "http://127.0.0.1:8080"; | ||
| 25 | +const USER = process.env.SHITHUB_USER || ""; | ||
| 26 | +const PASS = process.env.SHITHUB_PASS || ""; | ||
| 27 | + | ||
| 28 | +// Each entry: { name, path, requiresAuth, axeOpts? }. | ||
| 29 | +const ROUTES = [ | ||
| 30 | + { name: "home (anon)", path: "/", requiresAuth: false }, | ||
| 31 | + { name: "signup", path: "/signup", requiresAuth: false }, | ||
| 32 | + { name: "login", path: "/login", requiresAuth: false }, | ||
| 33 | + { name: "explore", path: "/explore", requiresAuth: false }, | ||
| 34 | + | ||
| 35 | + // Authenticated surfaces. SHITHUB_USER + SHITHUB_PASS must be set; | ||
| 36 | + // these are the routes most likely to have form-error association | ||
| 37 | + // and focus-trap regressions. | ||
| 38 | + { name: "dashboard", path: "/", requiresAuth: true }, | ||
| 39 | + { name: "settings/profile", path: "/settings/profile", requiresAuth: true }, | ||
| 40 | + { name: "settings/2fa", path: "/settings/security/2fa", requiresAuth: true }, | ||
| 41 | + { name: "new repo form", path: "/new", requiresAuth: true }, | ||
| 42 | + { name: "notifications", path: "/notifications", requiresAuth: true }, | ||
| 43 | +]; | ||
| 44 | + | ||
| 45 | +async function login(page) { | ||
| 46 | + if (!USER || !PASS) { | ||
| 47 | + throw new Error("SHITHUB_USER and SHITHUB_PASS must be set for authenticated routes"); | ||
| 48 | + } | ||
| 49 | + await page.goto(`${BASE}/login`, { waitUntil: "networkidle0" }); | ||
| 50 | + await page.type('input[name="username"]', USER); | ||
| 51 | + await page.type('input[name="password"]', PASS); | ||
| 52 | + await Promise.all([ | ||
| 53 | + page.waitForNavigation({ waitUntil: "networkidle0" }), | ||
| 54 | + page.click('button[type="submit"]'), | ||
| 55 | + ]); | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +function summarizeViolations(name, results) { | ||
| 59 | + const high = results.violations.filter(v => v.impact === "critical" || v.impact === "serious"); | ||
| 60 | + const low = results.violations.filter(v => v.impact !== "critical" && v.impact !== "serious"); | ||
| 61 | + console.log(`\n=== ${name} ===`); | ||
| 62 | + console.log(` passes: ${results.passes.length}, incomplete: ${results.incomplete.length}, violations: ${results.violations.length}`); | ||
| 63 | + if (high.length > 0) { | ||
| 64 | + console.log(` HIGH SEVERITY (${high.length}):`); | ||
| 65 | + for (const v of high) { | ||
| 66 | + console.log(` - [${v.impact}] ${v.id}: ${v.help}`); | ||
| 67 | + console.log(` ${v.helpUrl}`); | ||
| 68 | + console.log(` affected nodes: ${v.nodes.length}`); | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | + if (low.length > 0) { | ||
| 72 | + console.log(` Lower severity (${low.length}):`); | ||
| 73 | + for (const v of low) { | ||
| 74 | + console.log(` - [${v.impact}] ${v.id}: ${v.help}`); | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + return high.length; | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +async function main() { | ||
| 81 | + const browser = await puppeteer.launch({ | ||
| 82 | + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], | ||
| 83 | + }); | ||
| 84 | + const page = await browser.newPage(); | ||
| 85 | + await page.setViewport({ width: 1280, height: 1024 }); | ||
| 86 | + | ||
| 87 | + let totalHigh = 0; | ||
| 88 | + let didLogin = false; | ||
| 89 | + for (const route of ROUTES) { | ||
| 90 | + if (route.requiresAuth && !didLogin) { | ||
| 91 | + try { | ||
| 92 | + await login(page); | ||
| 93 | + didLogin = true; | ||
| 94 | + } catch (e) { | ||
| 95 | + console.log(`SKIP authenticated routes: ${e.message}`); | ||
| 96 | + break; | ||
| 97 | + } | ||
| 98 | + } | ||
| 99 | + await page.goto(`${BASE}${route.path}`, { waitUntil: "networkidle0" }); | ||
| 100 | + const results = await new AxePuppeteer(page) | ||
| 101 | + .withTags(["wcag2a", "wcag2aa", "best-practice"]) | ||
| 102 | + .analyze(); | ||
| 103 | + totalHigh += summarizeViolations(route.name, results); | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + await browser.close(); | ||
| 107 | + | ||
| 108 | + console.log(`\nTotal high-severity violations: ${totalHigh}`); | ||
| 109 | + process.exit(totalHigh === 0 ? 0 : 1); | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +main().catch(err => { | ||
| 113 | + console.error(err); | ||
| 114 | + process.exit(2); | ||
| 115 | +}); | ||
tests/a11y/pa11y-config.jsonadded@@ -0,0 +1,30 @@ | |||
| 1 | +{ | ||
| 2 | + "_comment": "pa11y-ci configuration. Run via `npx pa11y-ci --config tests/a11y/pa11y-config.json` against a running shithub instance. The default URL list targets the home + auth surfaces; sign-in-required pages are configured separately because pa11y-ci doesn't manage cookies — for those, use the axe-core runner instead.", | ||
| 3 | + "defaults": { | ||
| 4 | + "standard": "WCAG2AA", | ||
| 5 | + "timeout": 30000, | ||
| 6 | + "wait": 500, | ||
| 7 | + "ignore": [ | ||
| 8 | + "WCAG2AA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr", | ||
| 9 | + "WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2" | ||
| 10 | + ], | ||
| 11 | + "viewport": { | ||
| 12 | + "width": 1280, | ||
| 13 | + "height": 1024 | ||
| 14 | + }, | ||
| 15 | + "chromeLaunchConfig": { | ||
| 16 | + "args": [ | ||
| 17 | + "--no-sandbox", | ||
| 18 | + "--disable-setuid-sandbox", | ||
| 19 | + "--disable-dev-shm-usage" | ||
| 20 | + ] | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + "urls": [ | ||
| 24 | + "http://127.0.0.1:8080/", | ||
| 25 | + "http://127.0.0.1:8080/signup", | ||
| 26 | + "http://127.0.0.1:8080/login", | ||
| 27 | + "http://127.0.0.1:8080/explore", | ||
| 28 | + "http://127.0.0.1:8080/-/health" | ||
| 29 | + ] | ||
| 30 | +} | ||