/** * Axe accessibility pass -- desktop (chromium project). * * Scoped set: home, one tool per modality, editor, settings, login. * Runs in the default locale (en) and one RTL locale (ar). * * Uses a baseline file (a11y-baseline.json) to avoid failing on known * violations while catching any NEW regressions. To update the baseline * after fixing violations, run with A11Y_UPDATE_BASELINE=1. */ import fs from "node:fs"; import path from "node:path"; import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "./helpers"; // ---- Baseline machinery ---- interface BaselineFile { _comment: string; violations: Record; } const BASELINE_PATH = path.join(__dirname, "a11y-baseline.json"); function loadBaseline(): BaselineFile { try { return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf-8")); } catch { return { _comment: "", violations: {} }; } } function saveBaseline(baseline: BaselineFile): void { fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`); } interface ViolationEntry { id: string; impact: string | undefined; description: string; nodes: { length: number }[]; } function buildKey(pageKey: string, ruleId: string): string { return `${pageKey}:${ruleId}`; } /** * Run axe on the current page and assert no NEW critical/serious violations * beyond the committed baseline. */ async function auditPage( page: import("@playwright/test").Page, pageKey: string, baseline: BaselineFile, newViolations: { key: string; impact: string; description: string; count: number }[], allViolations: { key: string; impact: string; description: string; count: number }[], ) { const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "best-practice"]) .analyze(); for (const v of results.violations as ViolationEntry[]) { const key = buildKey(pageKey, v.id); const entry = { key, impact: v.impact ?? "unknown", description: v.description, count: v.nodes.length, }; allViolations.push(entry); // If this violation is NOT in the baseline, it is new if (!baseline.violations[key]) { newViolations.push(entry); } } } // ---- Scoped page set ---- const PAGES_EN = [ { key: "home-en", path: "/", needsAuth: true }, { key: "image-resize-en", path: "/image/resize", needsAuth: true }, { key: "video-convert-en", path: "/video/convert-video", needsAuth: true }, { key: "audio-convert-en", path: "/audio/convert-audio", needsAuth: true }, { key: "document-pdf-to-image-en", path: "/document/pdf-to-image", needsAuth: true }, { key: "data-csv-excel-en", path: "/data/csv-excel", needsAuth: true }, { key: "editor-en", path: "/editor", needsAuth: true }, { key: "login-en", path: "/login", needsAuth: false }, ]; const PAGES_AR = [ { key: "home-ar", path: "/", needsAuth: true, locale: "ar" }, { key: "image-resize-ar", path: "/image/resize", needsAuth: true, locale: "ar" }, { key: "editor-ar", path: "/editor", needsAuth: true, locale: "ar" }, { key: "login-ar", path: "/login", needsAuth: false, locale: "ar" }, ]; // ---- Tests ---- // Each test scans 9+ pages with axe; the default 30s timeout is tight. test.describe("Axe a11y audit -- desktop EN", () => { test.setTimeout(90_000); test("no new critical/serious violations on scoped pages", async ({ loggedInPage: page, browser, }) => { const baseline = loadBaseline(); const newViolations: { key: string; impact: string; description: string; count: number }[] = []; const allViolations: { key: string; impact: string; description: string; count: number }[] = []; for (const p of PAGES_EN) { if (p.needsAuth) { await page.goto(p.path); await page.waitForLoadState("networkidle"); await page.waitForTimeout(500); await auditPage(page, p.key, baseline, newViolations, allViolations); } else { // Login page: use a fresh context without auth const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const anonPage = await ctx.newPage(); await anonPage.goto(p.path); await anonPage.waitForLoadState("networkidle"); await anonPage.waitForTimeout(500); await auditPage(anonPage, p.key, baseline, newViolations, allViolations); await ctx.close(); } } // Report all violations by severity const bySeverity: Record = {}; for (const v of allViolations) { bySeverity[v.impact] = (bySeverity[v.impact] || 0) + v.count; } console.log("a11y violation counts by severity (desktop EN):", JSON.stringify(bySeverity)); console.log(`total unique rules violated: ${allViolations.length}`); console.log(`baselined: ${allViolations.length - newViolations.length}`); console.log(`NEW (not in baseline): ${newViolations.length}`); if (newViolations.length > 0) { console.log("NEW violations:"); for (const v of newViolations) { console.log(` ${v.key} [${v.impact}] (${v.count} nodes): ${v.description}`); } } // Update baseline if requested (skip assertion when generating baseline) if (process.env.A11Y_UPDATE_BASELINE === "1") { for (const v of allViolations) { baseline.violations[v.key] = { impact: v.impact, description: v.description, count: v.count, }; } saveBaseline(baseline); console.log("Baseline updated -- skipping assertion."); return; } // Gate: fail only on NEW critical or serious violations const newCriticalSerious = newViolations.filter( (v) => v.impact === "critical" || v.impact === "serious", ); expect( newCriticalSerious, `${newCriticalSerious.length} NEW critical/serious a11y violation(s) found. ` + `Run with A11Y_UPDATE_BASELINE=1 to baseline after review.\n` + newCriticalSerious.map((v) => ` ${v.key} [${v.impact}]: ${v.description}`).join("\n"), ).toHaveLength(0); }); }); test.describe("Axe a11y audit -- desktop AR (RTL)", () => { test.setTimeout(60_000); test("no new critical/serious violations on RTL pages", async ({ loggedInPage: page, browser, }) => { const baseline = loadBaseline(); const newViolations: { key: string; impact: string; description: string; count: number }[] = []; const allViolations: { key: string; impact: string; description: string; count: number }[] = []; for (const p of PAGES_AR) { if (p.needsAuth) { // Switch to Arabic locale await page.evaluate(() => { localStorage.setItem("snapotter-locale", "ar"); }); await page.goto(p.path); await page.waitForLoadState("networkidle"); await page.waitForTimeout(500); await auditPage(page, p.key, baseline, newViolations, allViolations); } else { const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const anonPage = await ctx.newPage(); // Set Arabic locale before navigating await anonPage.goto("/"); await anonPage.evaluate(() => { localStorage.setItem("snapotter-locale", "ar"); }); await anonPage.goto(p.path); await anonPage.waitForLoadState("networkidle"); await anonPage.waitForTimeout(500); await auditPage(anonPage, p.key, baseline, newViolations, allViolations); await ctx.close(); } } // Reset locale await page.evaluate(() => { localStorage.setItem("snapotter-locale", "en"); }); // Report const bySeverity: Record = {}; for (const v of allViolations) { bySeverity[v.impact] = (bySeverity[v.impact] || 0) + v.count; } console.log("a11y violation counts by severity (desktop AR):", JSON.stringify(bySeverity)); console.log(`total unique rules violated: ${allViolations.length}`); console.log(`NEW (not in baseline): ${newViolations.length}`); if (newViolations.length > 0) { console.log("NEW violations:"); for (const v of newViolations) { console.log(` ${v.key} [${v.impact}] (${v.count} nodes): ${v.description}`); } } if (process.env.A11Y_UPDATE_BASELINE === "1") { for (const v of allViolations) { baseline.violations[v.key] = { impact: v.impact, description: v.description, count: v.count, }; } saveBaseline(baseline); console.log("Baseline updated -- skipping assertion."); return; } const newCriticalSerious = newViolations.filter( (v) => v.impact === "critical" || v.impact === "serious", ); expect( newCriticalSerious, `${newCriticalSerious.length} NEW critical/serious a11y violation(s) in AR locale.\n` + newCriticalSerious.map((v) => ` ${v.key} [${v.impact}]: ${v.description}`).join("\n"), ).toHaveLength(0); }); });