metalfrom.eu/apps/admin/test/e2e/a11y.spec.js
Nicolas Fryder f14060720b
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(admin): refonte du panneau par intention, vue Localisations, tests e2e
Le panneau était découpé par table SQL, pas par question que se pose l'admin.
Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen
de voir ce qui coince réellement.

Redécoupage — un onglet = une question
- Pilotage      : est-ce que ça tourne ? (fusionne l'ancien « Actions »)
- Groupes       : trouver et corriger
- Localisations : qu'est-ce qui coince dans le géocodage ?  ← NOUVEAU
- Journal       : que s'est-il passé ?
- LLM & coûts   : combien ça coûte ?

Pilotage : chaque chiffre problématique porte son action
- Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état
- Alertes actionnables : « 12 localisations en erreur » + [Examiner] +
  [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet
- Déclencheurs de traitements inline, opérations destructives repliées
- L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais
  consultés) est retiré

Localisations : la vue qui manquait totalement
L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen
de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des
milliers d'appels Geoapify/Groq facturés.
- GET  /admin/api/locations          liste filtrable (statut, lieu, groupe, pays)
- POST /admin/api/locations/:id/requeue     relance UNE ligne
- PATCH /admin/api/locations/:id            saisie manuelle des coordonnées
- Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle

Groupes : recherche d'abord
Une barre de recherche et des filtres rapides en chips remplacent les 8 champs
texte ; les filtres avancés sont repliés.

Accessibilité
- Lignes de tableau activables au clavier (role=button, tabindex, Entrée)
- Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine
- Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1).
  Les messages d'erreur étaient difficiles à lire. Ajout de --err-text /
  --ok-text (~6,5:1) pour les usages en couleur de texte.
  Détecté par les nouveaux tests axe sur les vues rendues.
- Statuts affichés en français au lieu des valeurs brutes de la base

Tests
- 36 tests API sur les trois nouveaux endpoints (allowlist de statuts,
  bornes des coordonnées, audit, 404)
- 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une
  topologie identique à la production (statique servi + /admin/* proxifié)
- Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la
  coquille vide du dashboard, tout étant construit en JavaScript
- e2e branché sur le hook pre-push, pas sur `check` : la boucle de
  développement reste à 6 s, le push coûte 28 s

Correction trouvée par les tests
Le routeur ne séparait pas la query string du nom de vue :
« #/locations?status=error » ne correspondait à aucun alias et retombait sur
Pilotage — les liens des alertes ne fonctionnaient pas.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:05:29 +02:00

89 lines
3.7 KiB
JavaScript

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);
});
});