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 | +} | |