metalfrom.eu/apps/web/test/a11y.test.js
Nicolas FRYDER b5e3ef91ab
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
test: couvrir adminAuth, réparer la CI Python et une couverture annoncée à tort
adminAuth.js était le fichier le moins couvert du dépôt — 52 % de score de
mutation, 22 mutants sans aucune couverture — alors qu'il porte JWT, bcrypt et
le verrouillage de compte. seedAdminUser, recordLoginAttempt, markLoginSuccess
et writeAuditLog n'avaient aucun test direct. writeAuditLog était le plus
gênant : il peut cesser d'enregistrer sans qu'aucune action admin n'échoue et
sans qu'aucun test ne rougisse, et un journal d'audit muet ne se remarque que
le jour où on en a besoin.

Mesuré après coup : adminAuth.js passe de 52,31 % à 84,29 %, les mutants sans
couverture de 22 à 1, et le score global de 82,20 % à 88,85 %.

Le job Python de la CI n'installait que ruff, pytest et pip-audit : psycopg2,
beautifulsoup4, lxml et hypothesis manquaient, et il aurait échoué dès la
collecte — ce que personne n'a jamais vu, faute de runner enregistré. Il part
désormais de requirements-dev.txt, et lance aussi la suite du crawler, qui
n'était pas exécutée du tout.

requirements-dev.txt épinglait psycopg2-binary==2.9.9, sans roue pour Python
>= 3.13 : la suite entière s'arrêtait dès la collecte sur un poste par ailleurs
sain. Épinglages assouplis en bornes basses.

Corrige enfin le commentaire de a11y.test.js, qui renvoyait à « l'audit
Lighthouse manuel du README de la CI » pour justifier deux règles désactivées.
Cet audit n'existe pas : README-CI.md documente le test e2e de l'admin, qui
couvre bien le contraste mais UNIQUEMENT pour le dashboard. Pour le site
public, ces règles ne sont vérifiées nulle part. La couverture n'a pas changé,
seule sa description était fausse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:49:04 +02:00

103 lines
3.9 KiB
JavaScript

// @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 <html lang> et <head>.
document.documentElement.innerHTML = html
.replace(/<!doctype html>/i, "")
.replace(/<\/?html[^>]*>/gi, "");
const langMatch = html.match(/<html[^>]*\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([]);
});
});