~10 min de lecture
axe-core sur un projet Angular : l'installer, le brancher et lire ce qu'il te dit
TL;DR
axe-core est le moteur derrière l'extension axe DevTools et la catégorie Accessibility de Lighthouse. En tant que bibliothèque, il se branche à deux endroits : dans Vitest sur un composant, dans Playwright sur les pages servies. Le premier est un lint local : mesuré sur une page de ce site, le composant monté en jsdom - le DOM sans layout dans lequel tourne ta suite - ne fait mordre que 15 des 40 règles que la page servie déclenche. Le second est le vrai audit. Entre les deux, quatre pièges qui coûtent chacun une demi-heure : browser.newPage() refusé, les requêtes tierces qui font traîner l'audit, l'ordre du chaînage .options() / .withTags() qui efface silencieusement ta configuration, et l'absence d'attente d'état en SSR, sans laquelle deux exécutions identiques rendent deux verdicts différents.
L'installer
Premier réflexe, chercher l'intégration Vitest officielle. Elle n'existe pas. @axe-core/vitest renvoie un 404 sur npm : Deque publie notamment des intégrations Playwright, Puppeteer, WebdriverJS, WebdriverIO et React, plus une CLI - rien pour Vitest. Côté communauté, vitest-axe sert encore sa 0.1.0 d'octobre 2022 sous le tag latest.
Ce n'est pas un problème : axe-core s'utilise très bien seul. C'est lui le moteur, et il porte tout le catalogue de règles.
# niveau composant : le moteur, rien d'autre
pnpm add -D axe-core
# niveau page : le pilote de navigateur et le pont vers axe
pnpm add -D @axe-core/playwright playwright
Les deux niveaux sont indépendants : pour commencer, la première commande suffit.
Ce qu'axe te rend
axe.run() ne rend pas une liste de problèmes, il rend quatre listes. Les connaître change ce que tu peux en faire :
| Clé | Contenu |
|---|---|
violations |
la règle a échoué ; chaque entrée porte ses nodes, les éléments DOM incriminés |
passes |
la règle s'est exécutée et a validé |
incomplete |
axe n'a pas pu conclure et te renvoie la décision |
inapplicable |
la règle n'avait rien à examiner sur cette page |
L'assertion que tout le monde écrit ne lit qu'une seule de ces listes :
expect(results.violations).toEqual([]);
Garde incomplete en tête : c'est là qu'atterrissent les contrastes qu'axe ne sait pas calculer, et sur un design system à dégradés ça se compte en milliers de nœuds.
Choisir cette assertion est le premier réglage. Le second, celui qui décide de tout, c'est le jeu de tags :
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
Ce jeu sélectionne 99 règles sur les 105 du catalogue (axe.getRules(TAGS).length). Attention au verbe : sélectionner n'est pas exécuter. Sur une page réelle j'en compte 89 qui tournent. Des dix restantes, sept portent le tag experimental et les trois dernières deprecated : axe embarque une liste d'exclusion par défaut, tagExclude = ['experimental', 'deprecated'], qui s'applique par défaut à toute sélection par tags - et même à l'appel sans runOnly. Trois façons d'en sortir : nommer le tag dans la sélection, activer la règle nommément, ou sélectionner par règle avec .withRules(). On y revient au piège 3.
Et si tu te limites à wcag2a pour « ne garder que la norme », tu perds toute la famille landmark - les régions de repère (main, nav, header, footer) qu'un lecteur d'écran propose en navigation directe. axe y range neuf règles, toutes best-practice, aucune rattachée à un critère WCAG.
Niveau composant, dans Vitest
Une quinzaine de lignes, et ça tourne dans ta suite existante :
import axe from 'axe-core';
import { render } from '@testing-library/angular';
import { Faq } from './faq';
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
it("n'expose aucune violation d'accessibilité", async () => {
await render(Faq, { inputs: { items } });
const results = await axe.run(document.body, {
runOnly: { type: 'tag', values: TAGS },
});
expect(results.violations).toEqual([]);
});
Ce niveau attrape ce qui tient dans un fragment : un alt manquant, un label non associé, un aria-* interdit, une liste de définitions mal formée. Mais il faut savoir ce qu'il ne voit pas, sinon tu prendras un vert pour un audit. Sur une même page de ce site, même axe et mêmes tags : 40 règles trouvent de la matière sur la page servie, contre 15 sur le composant monté en jsdom (environment: 'jsdom' dans ta config ; par défaut Vitest tourne en node, sans DOM du tout). Les 49 autres règles exécutées sur la page sont inapplicable : 40 + 49 = les 89 du décompte précédent.
Deux causes distinctes, à ne pas confondre :
- jsdom n'est pas un navigateur.
color-contrastne mesure donc rien : la règle abandonne avant d'avoir regardé une couleur, et ressort 1 nœudincomplete, 0pass. Dans la sortie :Skipping color-contrast rule- axe a besoin d'uncanvas, que jsdom n'implémente pas. Limite de l'environnement, rien à y faire à ce niveau. - Le contexte passé à
axe.run()borne l'arbre.document.bodyexclut<html>et<head>, doncdocument-title,html-has-lang,landmark-one-mainetpage-has-heading-onesortentinapplicable;bypass, seule règle du catalogue marquéepageLevel, ne s'exécute même pas et n'apparaît dans aucune des quatre listes. Passedocument, et les cinq reviennent : 20 règles au lieu de 15. C'est ton contexte qui les tait, pas jsdom.
Et le piège qui coûte le plus cher : certaines règles ne se taisent pas, elles passent à tort. landmark-no-duplicate-main valide un composant qui viole la règle une fois monté dans le shell, parce que le DOM du test ne contient qu'un seul <main> : celui de la page.
Retiens la frontière : le niveau composant est un lint local, pas un audit.
Niveau page, dans Playwright
C'est là que tu trouves les vrais bugs. Prérequis : un build servi. axe a besoin du document complet, CSS appliqué et JavaScript exécuté.
pnpm build
npx http-server dist/mon-app/browser -p 4202 # ou le script de serve de ton repo
Le script d'audit est un script Node ordinaire, pas une spec. Il demande Node 22 ou plus, pour fs.globSync.
import { globSync } from 'node:fs';
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
const BASE = 'http://127.0.0.1:4202';
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
// Si tes routes sont prérendues, la liste est déjà sur le disque.
const routes = globSync('dist/mon-app/browser/**/index.html').map((file) =>
file.replace(/^dist\/mon-app\/browser/, '').replace(/\/index\.html$/, ''),
);
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
const faulty = [];
for (const route of routes) {
await page.goto(`${BASE}${route}/`, { waitUntil: 'load' });
const { violations } = await new AxeBuilder({ page })
.withTags(TAGS)
.analyze();
if (violations.length > 0) faulty.push({ route, violations });
}
await browser.close();
console.table(faulty.map((f) => ({ route: f.route, n: f.violations.length })));
process.exitCode = faulty.length > 0 ? 1 : 0;
Attention : cette liste ne contient que ce qui a été écrit sur le disque. Une route en RenderMode.Server n'y figure pas, et disparaît de l'audit sans le moindre signal.
Ce script évite par construction deux des quatre pièges de la section suivante : il ouvre un newContext() et n'appelle jamais .options(). Restent les deux autres, dont un qui change son verdict.
Les quatre pièges d'intégration
1. browser.newPage() est refusé. AxeBuilder lève un Error: Please use browser.newContext(), suivi d'un lien vers la page d'erreurs de Deque : le raccourci qui crée un contexte implicite ne lui suffit pas.
2. Coupe les requêtes tierces. Analytics, polices distantes, widgets de paiement : chacun peut faire traîner l'événement load de plusieurs secondes, à multiplier par le nombre de routes. Mon premier parcours a duré 19 minutes ; les tiers coupés, le même - attente d'état comprise - est tombé à 95 secondes.
await context.route('**/*', (route) =>
route.request().url().startsWith(BASE) ? route.continue() : route.abort(),
);
3. .options() écrase ta configuration au lieu de la compléter. C'est le piège le plus silencieux, parce que rien n'échoue : appelée après .withTags(), elle efface le runOnly et l'audit repart sur le jeu par défaut. Le test : .withTags(['wcag2aa']) seul rapporte 3 règles ; avec .options() d'abord pour rallumer une règle hors du jeu, 4 - les 3 du tag plus celle qu'on vient d'activer, donc les deux réglages ont tenu. Dans l'ordre inverse, 90 : le runOnly a sauté, et la règle rallumée se noie dans le jeu par défaut.
Le bon ordre, .options() d'abord :
await new AxeBuilder({ page })
.options({ rules: { 'label-content-name-mismatch': { enabled: true } } })
.withTags(TAGS)
.analyze();
C'est aussi comme ça qu'on rallume une règle experimental. Celle de l'exemple attrape un aria-label qui contredit le texte visible (aria-label="Annuler" sur un bouton qui affiche « Envoyer »). C'est le critère WCAG 2.5.3 : la règle est bien sélectionnée par le jeu de tags, et pourtant jamais exécutée tant que tu ne l'actives pas.
4. Sur une app SSR ou SSG, attends un état, jamais un temps. Le HTML prérendu et le DOM hydraté ne donnent pas le même verdict : sur ce blog, la coloration syntaxique pose côté client un tabindex sur les blocs de code, et tant qu'elle n'est pas passée, chaque bloc débordant est une région scrollable inatteignable au clavier.
await page.waitForFunction(
() => document.querySelectorAll('pre:not([tabindex])').length === 0,
);
Sans cette attente, quatre exécutions de la même commande m'ont donné 59, 51, 55 puis 62 pages fautives. Ces chiffres comptent en plus les pages où la coloration n'était pas encore passée au moment du scan : l'attente posée, le même parcours rend le décompte stable annoncé plus bas, et tout le delta tient dans scrollable-region-focusable. Un audit non déterministe, c'est un audit qu'on finit par désactiver.
« Lequel des deux DOM est le bon ? » n'est pas la bonne question : les deux. L'hydraté est ce que voit la majorité de tes visiteurs ; le prérendu, ce que voit n'importe qui tant que le JavaScript n'a pas repris la main - et définitivement si le bundle ne charge jamais. Pour auditer celui-là, coupe le JavaScript avant la navigation :
await context.route('**/*.js', (route) => route.abort());
Deux passes sur le même parcours, donc, et deux rapports - la même dualité que l'hydratation incrémentale rend visible côté performances.
Le brancher en CI
Deux assertions, pas une, et à chacun des deux niveaux :
// `countNodes` somme les `nodes` de chaque entrée de la liste.
expect(results.violations).toEqual([]);
expect(countNodes(results.incomplete)).toBeLessThanOrEqual(BASELINE[route]);
BASELINE, c'est le chiffre relevé le jour où tu poses l'assertion : pas une cible, un cliquet qui empêche de reculer. Et un cliquet par unité auditée - la route au niveau page, le composant au niveau Vitest. Une table indexée, jamais une constante pour tout le site.
La seconde est celle qu'on oublie. Sur ce site, incomplete cumule plus de 28 000 nœuds, presque tous du contraste qu'axe ne sait pas calculer parce que le fond est un dégradé. Aucun n'est un bug prouvé, aucun n'est innocenté : impossible de les traiter un par un, mais tu peux surveiller la taille de la pile - si elle grossit d'un coup, quelqu'un a introduit un fond que personne n'a arbitré. Et l'échelle n'a rien d'uniforme, d'une trentaine de nœuds sur une page courte à 946 sur l'index du blog : un cliquet global laisserait passer une grosse régression sur une petite page.
Mets aussi le lint template en amont : les règles @angular-eslint/template/* (click-events-have-key-events, alt-text, label-has-associated-control, valid-aria, no-positive-tabindex) attrapent à l'écriture ce qu'axe n'a alors plus à retrouver à l'exécution. Et la couverture Vitest te dira si les composants concernés sont montés ne serait-ce qu'une fois.
Et garde en tête ce qu'axe ne voit pas, pour deux raisons bien différentes. Le focus qui ne bouge pas au changement de route et l'enfermement du focus dans une modale maison sont des interactions : là, c'est structurel, aucun analyseur d'instantané ne les provoquera. L'ordre de tabulation quand le CSS réordonne visuellement, lui, serait parfaitement observable - il suffirait de confronter l'ordre du document à l'ordre visuel. Ce n'est donc pas une impossibilité, juste une règle que personne n'a écrite. Dans les deux cas, ça se teste au clavier, à la main, une fois pour toutes.
Ce que ça a donné chez moi
173 routes prérendues auditées en 95 secondes, sur le DOM hydraté, attente d'état posée : 15 pages fautives, 61 nœuds, 7 règles. Trois trouvailles méritent le détour, parce qu'elles ne sont pas propres à ce site :
- Un
aria-labelsur unedivsansrole, 39 fois sur une seule page. La spec ARIA 1.2 range le rôle implicite d'unediv(generic) parmi ceux auxquels on n'a pas le droit d'attacher un nom accessible. axe rend une violation quand l'élément n'a aucun texte, unincomplete« mal supporté » quand ladiven porte : dans les deux cas, tu ne peux pas compter dessus. Correctif :role="img"quand l'élément porte de l'information. - Un contraste à 3,06:1 : un composant avait posé sur fond sombre le token de couleur réservé aux panneaux clairs. La règle d'usage du token était documentée depuis des mois : une règle documentée n'est pas une règle tenue.
- Un
<main>de page monté dans le<main>du shell - le faux vert du niveau composant, en production. Le bug n'est dans aucun fichier : chacun est correct isolément ; le défaut naît de la composition. Et le test de la page exigeait ce markup.
Récap actionnable
axe-coreseul pour le niveau composant ;@axe-core/playwrightetplaywrightquand tu veux le vrai audit.- Une seule constante de tags, partagée entre les deux niveaux : sans
best-practice, tu perds les règles landmark. - Deux assertions en CI :
violationsà vide, et la taille deincompletesous un cliquet par unité auditée, relevé le jour J. newContext(), jamaisnewPage():AxeBuilderrefuse le raccourci. Et coupe tout ce qui n'est pas ton origine - c'est là qu'est l'ordre de grandeur..options()avant.withTags(), sinon ta configuration de tags est effacée sans un mot.- En SSR ou SSG, attends un état du DOM, jamais un
waitForTimeout- et audite les deux DOM, hydraté et prérendu.
Rien de tout ça ne se trouve en relisant du code.
Les chiffres de la section « chez moi » décrivent ce site au moment de l'audit, avant les correctifs qu'il a déclenchés.