From eca3479059fff14e41a7e1a37d1c84484bf65a50 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 12:09:32 +0200 Subject: [PATCH] test(a11y): gate primary workspaces with axe --- docs/work-programmes/master-progress.md | 2 + job-tracker-ui/e2e/accessibility.spec.ts | 40 +++++++++ job-tracker-ui/package-lock.json | 21 ++++- job-tracker-ui/package.json | 1 + .../src/components/GoogleAuthCard.tsx | 2 +- job-tracker-ui/src/components/JobTable.tsx | 24 +++--- job-tracker-ui/src/layout/AppShell.tsx | 82 +++++++++++-------- job-tracker-ui/src/theme.ts | 2 +- job-tracker-ui/src/views/LandingPage.tsx | 2 +- 9 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 job-tracker-ui/e2e/accessibility.spec.ts diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 6625f00..2554292 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -52,6 +52,7 @@ Updated: 2026-08-30 - Added a minimal public `/ready` dependency probe alongside the existing `/health` liveness probe. Nginx exposes both, deployment validation now checks the frontend, API liveness, database readiness, and public auth configuration separately, while detailed dependency metadata remains restricted to Admin/System. - Hardened deployment replacement semantics: backend/frontend images receive exact commit tags, the prior running core images are retained as the rollback release, and any post-replacement failure automatically restores both previous services while keeping the deployment result failed. Removed the non-actionable blanket `compose pull || true` suppression. - Completed the next lifecycle-controller boundary by moving status changes and deterministic email status suggestions out of the core controller. The applied-date invariant and cleared-date audit event now live in `JobLifecycleEvents`, so full edits and status-only updates cannot drift. +- Added axe-powered WCAG A/AA browser gates for public entry points and the Dashboard, Jobs, Kanban, Career, Settings, and Admin/System workspaces. Fixed the shared violations they exposed: primary-action contrast, dark-surface language-toggle contrast, account-avatar contrast, sidebar list semantics, job-filter accessible names, and the Google sign-in wrapper role. - Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry. - Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path. - Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry. @@ -100,6 +101,7 @@ Updated: 2026-08-30 - Health/readiness split: Release build passed with 0 warnings/errors, the complete backend suite passed 736/736, and the focused Playwright probe passed 1/1 against a real disposable SQLite-backed API process. - Deployment rollback configuration: Docker Compose configuration validation passed with non-secret fixture values. Runtime rollback rehearsal remains pending because the local Linux Docker daemon is offline. - Status lifecycle extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736, including applied-date preservation, status suggestions, and cross-tenant not-found behavior. +- Accessibility package: public axe flow passed 1/1 and authenticated axe flow passed 1/1 across six workspaces; ESLint passed with zero warnings; all 60 frontend suites and 260/260 tests passed; optimized Next build and integrated TypeScript passed; npm audit reported zero vulnerabilities after adding `@axe-core/playwright`. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/e2e/accessibility.spec.ts b/job-tracker-ui/e2e/accessibility.spec.ts new file mode 100644 index 0000000..8a86822 --- /dev/null +++ b/job-tracker-ui/e2e/accessibility.spec.ts @@ -0,0 +1,40 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +async function expectAccessible(page: Page, label: string) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + + expect(results.violations, `${label}: ${JSON.stringify(results.violations, null, 2)}`).toEqual([]); +} + +async function login(page: Page) { + await page.goto("/login"); + await page.getByLabel("Email").fill("e2e@example.test"); + await page.getByLabel("Current password").fill("E2ePassword123!"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en")); +}); + +test("public entry points meet automated WCAG A and AA checks", async ({ page }) => { + for (const route of ["/", "/login"]) { + await page.goto(route); + await expect(page.locator("body")).toBeVisible(); + await expectAccessible(page, route); + } +}); + +test("primary authenticated workspaces meet automated WCAG A and AA checks", async ({ page }) => { + await login(page); + + for (const route of ["/dashboard", "/jobs", "/kanban", "/career", "/settings", "/admin/system"]) { + await page.goto(route); + await expect(page.locator("main")).toBeVisible(); + await expectAccessible(page, route); + } +}); diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 315dc48..3b1bbd3 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -34,6 +34,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", @@ -74,6 +75,19 @@ "dev": true, "license": "ISC" }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@azure/msal-browser": { "version": "5.17.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz", @@ -4259,7 +4273,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.62.1" @@ -10731,7 +10745,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.62.1" @@ -10750,7 +10764,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -10763,7 +10777,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index 1a0464e..7aba7a7 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -51,6 +51,7 @@ ] }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 7f216d7..8a06596 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -192,7 +192,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: } return clientId - ?
+ ?
: ; } diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 59415be..f992bdb 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -474,15 +474,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - {t("jobTableStatus")} - changeStatus(e.target.value)}> {[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => {s})} - {t("jobTableCompany")} - changeCompany(e.target.value as number | "All")}> {t("jobTableAll")} {selectedCompanyIsLoading ? Company {companyFilterId} : null} {companies.map((c) => {c.name})} @@ -500,8 +500,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {mode === "jobs" ? ( - {t("jobTableReadiness")} - changeReadiness(e.target.value as ReadinessFilter)}> {t("jobTableAllReadiness")} {t("jobTableNeedsWork")} {t("jobTableInterviewStage")} @@ -552,15 +552,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col /> - {t("jobTableStatus")} - changeStatus(e.target.value)}> {[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => {s})} - {t("jobTableCompany")} - changeCompany(e.target.value as number | "All")}> {t("jobTableAll")} {selectedCompanyIsLoading ? Company {companyFilterId} : null} {companies.map((c) => {c.name})} @@ -578,8 +578,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {mode === "jobs" ? changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null} {mode === "jobs" ? ( - {t("jobTableReadiness")} - changeReadiness(e.target.value as ReadinessFilter)}> {t("jobTableAllReadiness")} {t("jobTableNeedsWork")} {t("jobTableInterviewStage")} diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index 9c3af57..e7a6684 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -13,6 +13,7 @@ import { Drawer, IconButton, List, + ListItem, ListItemButton, ListItemIcon, ListItemText, @@ -168,38 +169,38 @@ export default function AppShell({ {rows.map((item) => { const selected = item.to === activeTo; return ( - onNavigate(item.to)} - title={desktopNavCollapsed ? item.label : undefined} - sx={{ - borderRadius: 2, - mb: 0.5, - minHeight: 44, - px: desktopNavCollapsed ? 1 : 1.5, - justifyContent: desktopNavCollapsed ? "center" : "flex-start", - border: "1px solid transparent", - color: SIDEBAR_TEXT_MUTED, - "&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT }, - "&.Mui-selected": { - backgroundColor: SIDEBAR_SELECTED_BG, - color: SIDEBAR_SELECTED_TEXT, - }, - "&.Mui-selected:hover": { - backgroundColor: SIDEBAR_SELECTED_BG, - }, - }} - > - - {item.badgeCount && item.badgeCount > 0 ? ( - 99 ? "99+" : item.badgeCount}> - {item.icon} - - ) : item.icon} - - {!desktopNavCollapsed ? : null} - + + onNavigate(item.to)} + title={desktopNavCollapsed ? item.label : undefined} + sx={{ + borderRadius: 2, + minHeight: 44, + px: desktopNavCollapsed ? 1 : 1.5, + justifyContent: desktopNavCollapsed ? "center" : "flex-start", + border: "1px solid transparent", + color: SIDEBAR_TEXT_MUTED, + "&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT }, + "&.Mui-selected": { + backgroundColor: SIDEBAR_SELECTED_BG, + color: SIDEBAR_SELECTED_TEXT, + }, + "&.Mui-selected:hover": { + backgroundColor: SIDEBAR_SELECTED_BG, + }, + }} + > + + {item.badgeCount && item.badgeCount > 0 ? ( + 99 ? "99+" : item.badgeCount}> + {item.icon} + + ) : item.icon} + + {!desktopNavCollapsed ? : null} + + ); })} @@ -319,7 +320,7 @@ export default function AppShell({ onClick={(e) => setUserMenuAnchor(e.currentTarget)} sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }} > - {initials} + {initials} ) : } @@ -427,7 +428,7 @@ export default function AppShell({ onClick={(e) => setUserMenuAnchor(e.currentTarget)} sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }} > - {initials} + {initials} @@ -556,10 +557,19 @@ export default function AppShell({ ); } -export function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) { +export function StackLanguageToggle({ language, setLanguage, onDark = false }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void; onDark?: boolean }) { const { t } = useI18n(); return ( - + diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index e7d16b5..cfbf92d 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -4,7 +4,7 @@ type PaletteLike = Record; // Single global brand accent -- matches the dark sidebar/landing page indigo used throughout // the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes. -const ACCENT = "#6366F1"; +const ACCENT = "#5B5BD6"; function buildPrimary(main: string) { return { diff --git a/job-tracker-ui/src/views/LandingPage.tsx b/job-tracker-ui/src/views/LandingPage.tsx index e8bd173..b90ed9a 100644 --- a/job-tracker-ui/src/views/LandingPage.tsx +++ b/job-tracker-ui/src/views/LandingPage.tsx @@ -99,7 +99,7 @@ export default function LandingPage() { Jobbjakt - + {t("landingSignIn")}