| 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 | }); |