Le dépôt n'avait aucun test, aucun linter, aucune vérification de types. Outillage - ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python - tsc --checkJs sur l'API (pas de TypeScript, juste la vérification) - Vitest : 401 tests JS ; pytest : 43 tests Python - Tests de mutation (Stryker), deux profils : logique pure et API complète - Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook, c'est donc la seule porte de qualité avant la mise en ligne - Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré) Sécurité - Injection SQL authentifiée dans resolve-conflict : `field` était interpolé dans le SET sans allowlist - timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401) - setErrorHandler écrasait tous les 4xx en 500 - .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part alors que leur absence casse toute connexion admin Annulation réelle des crawl_run (migration 014) - L'API posait status='error' sans que le crawler en sache rien : le process continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur) - Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler écrit lui-même status='cancelled' Cohérence géographique (migration 014) - Le trigger 013 supprimait les band_locations sans purger le point dénormalisé - L'édition admin de lat/lon n'atteignait jamais band_locations : la carte ignorait la correction. Override step_order = -1, dans une transaction Corrections - limit/offset NaN → 500 au lieu de 400 - OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie) - listen() sans catch, cast ::text en dur sur les colonnes numériques - /admin/api/logs ne renvoyait pas sa pagination - a11y : sélecteur de langue annoncé comme liste vide (role=option manquant) Nettoyage - apps/web/quizz-site supprimé (sans rapport avec le projet) - Code mort : openModal(), LANG_NAMES, double import, variables inutilisées - .dockerignore ajoutés ; node_modules racine n'était pas gitignoré Co-Authored-By: Claude <noreply@anthropic.com>
96 lines
3.5 KiB
JavaScript
96 lines
3.5 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 assumée : les règles qui nécessitent un vrai moteur de rendu
|
|
* (contraste de couleurs, cibles tactiles) ne peuvent pas s'évaluer sous jsdom
|
|
* et sont désactivées. Elles ne sont donc PAS couvertes ici — voir le README
|
|
* de la CI pour l'audit Lighthouse manuel qui les complète.
|
|
*/
|
|
|
|
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([]);
|
|
});
|
|
});
|