diff --git a/e2e/tests/workflows/accessibility-audit.spec.ts b/e2e/tests/workflows/accessibility-audit.spec.ts new file mode 100644 index 0000000..34104f1 --- /dev/null +++ b/e2e/tests/workflows/accessibility-audit.spec.ts @@ -0,0 +1,102 @@ +/* eslint-disable */ +/** + * ACCESSIBILITY AUDIT — axe-core automated WCAG check on key pages. + * + * Scans for: color contrast, missing labels, ARIA issues, keyboard nav, + * heading hierarchy, image alt text, form accessibility. + * + * Run headed: + * E2E_MAXIMIZED=1 npx playwright test --headed --project chromium accessibility-audit --workers=1 + */ +import { test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const M = 'Accessibility'; +const ADMIN: RoleName = 'hospital_admin'; + +type Page = import('@playwright/test').Page; + +async function login(page: Page) { await page.context().clearCookies(); await loginAndScope(page, ADMIN, M); } + +// Pages to audit (most user-facing) +const PAGES = [ + { url: '/', label: 'command-center' }, + { url: '/accounts/login/', label: 'login', auth: false }, + { url: '/complaints/', label: 'complaint-list' }, + { url: '/inquiries/', label: 'inquiry-list' }, + { url: '/observations/', label: 'observation-list' }, + { url: '/complaints/public/submit/', label: 'public-complaint-form', auth: false }, + { url: '/observations/new/', label: 'public-observation-form', auth: false }, + { url: '/core/public/submit/', label: 'public-landing', auth: false }, + { url: '/analytics/dashboard/', label: 'analytics-dashboard' }, + { url: '/projects/', label: 'projects-list' }, + { url: '/actions/', label: 'actions-list' }, + { url: '/rca/', label: 'rca-list' }, + { url: '/surveys/templates/', label: 'survey-templates' }, + { url: '/appreciation/', label: 'appreciation-list' }, + { url: '/notifications/inbox/', label: 'notifications-inbox' }, +]; + +test.describe('Accessibility Audit', () => { + for (const { url, label, auth = true } of PAGES) { + test(`${label}: WCAG audit`, async ({ page }) => { + attachObservers(page, M, ADMIN); + if (auth) { + await login(page); + } else { + await page.context().clearCookies(); + } + + await page.goto(`${BASE_URL}${url}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(1000); + + try { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + + const violations = results.violations; + const critical = violations.filter(v => v.impact === 'critical'); + const serious = violations.filter(v => v.impact === 'serious'); + const moderate = violations.filter(v => v.impact === 'moderate'); + const minor = violations.filter(v => v.impact === 'minor'); + + const totalNodes = violations.reduce((sum, v) => sum + v.nodes.length, 0); + + if (critical.length > 0 || serious.length > 0) { + const topIssues = [...critical, ...serious].slice(0, 5).map(v => + `${v.impact}: ${v.id} (${v.nodes.length} nodes) — ${v.description.slice(0, 80)}` + ); + observe(M, label, 'WARN', + `${critical.length} critical, ${serious.length} serious, ${moderate.length} moderate, ${minor.length} minor (${totalNodes} nodes total). Top: ${topIssues.join('; ')}`, + {}); + } else if (moderate.length > 0) { + observe(M, label, 'PASS', + `0 critical, 0 serious, ${moderate.length} moderate, ${minor.length} minor (${totalNodes} nodes). OK for WCAG AA.`, + {}); + } else { + observe(M, label, 'PASS', + `0 critical, 0 serious, 0 moderate, ${minor.length} minor. Clean.`, + {}); + } + } catch (e) { + observe(M, label, 'WARN', `axe scan failed: ${(e as Error).message.slice(0, 100)}`, {}); + } + }); + } +}); + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + const warns = OBS.filter((o) => o.status === 'WARN'); + console.log('\n=========== ACCESSIBILITY SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('Pages with accessibility issues:', warns.length); + for (const w of warns) { + console.log(` ⚠️ ${w.module}/${w.step}: ${w.detail.slice(0, 120)}`); + } + console.log('=============================================\n'); +}); diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index e20181e..afd621f 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -17,6 +17,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -242,6 +255,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -738,6 +761,7 @@ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, diff --git a/package-lock.json b/package-lock.json index 217c973..be56643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "sweetalert2": "^11.26.24" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.59.1", "@types/node": "^25.5.2", "tailwindcss": "^3.4.19", @@ -35,6 +36,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -260,6 +274,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -771,6 +795,7 @@ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, diff --git a/package.json b/package.json index 9a5aed5..eedc53f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "npm run build:css" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.59.1", "@types/node": "^25.5.2", "tailwindcss": "^3.4.19",