import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; /** * Accessibilité des vues RENDUES. * * Le test statique (apps/web/test/a11y.test.js) analyse le HTML livré : pour le * dashboard, ce n'est qu'une coquille vide, puisque tout est construit en * JavaScript. C'est donc ici, dans un vrai navigateur après rendu, que * l'accessibilité du dashboard est réellement vérifiée — contraste des couleurs * compris, ce que jsdom ne sait pas calculer. */ const VIEWS = [ ["pilotage", "Pilotage"], ["bands", "Groupes"], ["locations", "Localisations"], ["activity", "Journal"], ["llm", "LLM & coûts"], ]; async function analyse(page) { return new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); } function describeViolations(results) { return results.violations.map((v) => `[${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.map((n) => n.target.join(" ")).join("\n ")}`); } test.describe("Accessibilité des vues", () => { for (const [view, title] of VIEWS) { test(`${title} — aucune violation critique ou sérieuse`, async ({ page }) => { await page.goto("/"); await page.click(`.nav a[data-view="${view}"]`); await expect(page.locator(".view-head h1")).toHaveText(title); // Laisse les chargements asynchrones peindre leur contenu. await page.waitForLoadState("networkidle"); const results = await analyse(page); const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact)); expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]); }); } test("l'écran de connexion est accessible", async ({ page }) => { await page.context().clearCookies(); await page.goto("/"); await expect(page.locator("#login-screen")).toBeVisible(); const results = await analyse(page); const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact)); expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]); }); test("la fiche d'un groupe reste accessible une fois ouverte", async ({ page }) => { await page.goto("/"); await page.click('.nav a[data-view="bands"]'); await page.locator('tr[data-ma-id="1"]').click(); await expect(page.locator(".modal-backdrop .modal")).toBeVisible(); const results = await analyse(page); const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact)); expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]); }); }); test.describe("Navigation au clavier", () => { test("on atteint la navigation puis le contenu à la tabulation", async ({ page }) => { await page.goto("/"); await page.keyboard.press("Tab"); const firstFocus = await page.evaluate(() => document.activeElement?.getAttribute("data-view")); expect(firstFocus).toBe("pilotage"); }); test("le focus est piégé dans la fiche ouverte", async ({ page }) => { await page.goto("/"); await page.click('.nav a[data-view="bands"]'); await page.locator('tr[data-ma-id="1"]').click(); await expect(page.locator(".modal-backdrop")).toBeVisible(); // Vingt tabulations doivent toutes rester à l'intérieur de la modale : // sinon le focus repart derrière le voile, invisible pour l'utilisateur. for (let i = 0; i < 20; i++) await page.keyboard.press("Tab"); const inside = await page.evaluate(() => !!document.activeElement?.closest(".modal-backdrop")); expect(inside).toBe(true); }); });