// @vitest-environment jsdom import { describe, it, expect, beforeAll } from "vitest"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import axe from "axe-core"; /** * Tests d'accessibilité statiques. * * On charge le HTML livré tel quel dans jsdom et on passe axe-core dessus. * Pas de navigateur, pas de conteneur : ~1s pour les deux pages. * * Limite RÉELLEMENT non couverte : les règles qui exigent un moteur de rendu * (contraste de couleurs, taille des cibles tactiles) ne s'évaluent pas sous * jsdom et sont désactivées ci-dessous. * * Ce commentaire renvoyait auparavant à « l'audit Lighthouse manuel du README * de la CI ». Cet audit n'existe pas, et n'a jamais existé : README-CI.md * documente apps/admin/test/e2e/a11y.spec.js, qui couvre bien le contraste * dans un vrai navigateur — mais UNIQUEMENT pour le dashboard admin. Pour le * site public, ces deux règles ne sont vérifiées nulle part. Le dire * franchement vaut mieux que de renvoyer vers un filet imaginaire : la * couverture n'a pas changé, seule sa description était fausse. */ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../../.."); const PAGES = { "site public": path.join(REPO_ROOT, "apps/web/site/index.html"), "dashboard admin": path.join(REPO_ROOT, "apps/admin/site/index.html"), }; // Règles inapplicables sous jsdom (pas de layout ni de couleurs calculées). const DISABLED_RULES = { "color-contrast": { enabled: false }, "target-size": { enabled: false }, }; async function analyse(htmlPath) { const html = fs.readFileSync(htmlPath, "utf8"); // On remplace le document entier pour conserver et . document.documentElement.innerHTML = html .replace(//i, "") .replace(/<\/?html[^>]*>/gi, ""); const langMatch = html.match(/]*\blang="([^"]+)"/i); if (langMatch) document.documentElement.setAttribute("lang", langMatch[1]); return axe.run(document, { rules: DISABLED_RULES, resultTypes: ["violations"], }); } describe.each(Object.entries(PAGES))("a11y — %s", (label, htmlPath) => { let results; beforeAll(async () => { results = await analyse(htmlPath); }, 30000); it("n'a aucune violation critique ou sérieuse", () => { const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact)); const summary = blocking.map( (v) => `[${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.map((n) => n.target.join(" ")).join("\n ")}` ); expect(summary, `${label} — violations bloquantes :\n${summary.join("\n")}`).toEqual([]); }); it("déclare une langue de document", () => { expect(document.documentElement.getAttribute("lang")).toBeTruthy(); }); it("n'utilise pas de tabindex positif (casse l'ordre de tabulation)", () => { const positive = [...document.querySelectorAll("[tabindex]")].filter( (el) => Number(el.getAttribute("tabindex")) > 0 ); expect(positive.map((el) => el.outerHTML.slice(0, 80))).toEqual([]); }); it("donne un nom accessible à chaque contrôle de formulaire", () => { const unnamed = [...document.querySelectorAll("input, select, textarea")] .filter((el) => el.type !== "hidden") .filter((el) => { const id = el.getAttribute("id"); return !( el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") || el.getAttribute("title") || el.closest("label") || (id && document.querySelector(`label[for="${CSS.escape(id)}"]`)) ); }); expect(unnamed.map((el) => el.outerHTML.slice(0, 100))).toEqual([]); }); it("chaque image porte un attribut alt", () => { const noAlt = [...document.querySelectorAll("img")].filter((el) => !el.hasAttribute("alt")); expect(noAlt.map((el) => el.getAttribute("src"))).toEqual([]); }); });