tenseleyflow/shithub / 248b4bf

Browse files

S39: a11y tooling — pa11y-ci config + axe-core Puppeteer runner

Authored by espadonne
SHA
248b4bfaf4a882d5945a526eb07b5fa4e9634741
Parents
0dcdf42
Tree
66d67af

3 changed files

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