Pourquoi un filet de sécurité
L’accessibilité d’une extension n’est pas un livrable ponctuel. Elle se dégrade silencieusement à chaque refacto, à chaque nouveau composant, à chaque modale ajoutée à la va-vite. Sans contrôle automatique, on découvre les régressions soit chez les utilisateurs, soit jamais.
Pour SmartTab Organizer (extension Chrome / Firefox MV3, React + Radix Themes + WXT), je voulais un dispositif qui réponde à trois exigences :
- Détecter les régressions automatiquement, sur chaque pull request, sans dépendre de la mémoire ou de la bonne volonté.
- Distinguer ce qui se passe au niveau composant isolé de ce qui se passe à l’échelle de la page complète.
- Rester silencieux quand tout va bien, et bruyant quand quelque chose se dégrade vraiment.
Le résultat : une pipeline a11y à deux étages basée sur axe-core, qui produit un rapport consolidé publié en commentaire sticky de PR. Cet article décrit l’architecture, les choix techniques, et les pièges évités.
Vue d’ensemble
Le dispositif combine quatre couches qui se complètent :
- Lint statique :
eslint-plugin-jsx-a11y(preset recommended), appliqué uniquement aux fichierssrc/**/*.tsx. Première barrière contre les erreurs évidentes (alt manquant, role invalide, etc.). - Audit Storybook : axe-core injecté dans chaque story via
@storybook/test-runner. Mesure l’accessibilité des composants en isolation. - Audit Playwright E2E : axe-core appelé à des points clés des specs E2E via un helper
auditPage(). Mesure l’accessibilité au niveau page entière, dans des scénarios réels. - Consolidation et publication : un script Node assemble les deux rapports, génère un
summary.md, qui est publié comme commentaire sticky sur la PR.
Les deux étages d’audit (Storybook et E2E) sont complémentaires : Storybook teste les composants hors contexte, E2E teste les pages assemblées. Ils partagent le même format de sortie et les mêmes seuils de sévérité.
Couche 1 : lint statique
La configuration ESLint applique le preset jsx-a11y aux fichiers .tsx :
{
files: ['src/**/*.tsx'],
...jsxA11y.flatConfigs.recommended,
},
C’est la couche la moins coûteuse : exécutée à chaque sauvegarde dans l’IDE et bloquante en CI. Elle attrape les violations syntaxiques (<img> sans alt, role invalide, label de formulaire manquant) avant même qu’elles soient testées au runtime.
Couche 2 : audit Storybook avec axe-core
Le test-runner Storybook
Storybook expose un test-runner basé sur Jest et Playwright. Couplé à axe-playwright, il permet d’exécuter axe-core sur chaque story du projet de manière automatisée.
Le hook se résume à : preVisit injecte axe, postVisit récupère les violations et les écrit dans un shard JSONL. Voici la partie qui compte :
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
await configureAxe(page, {
rules: DISABLED_RULES_FOR_STORYBOOK.map(id => ({ id, enabled: false })),
});
},
async postVisit(page, context) {
// attendre la fin du chargement des traductions
await page.waitForFunction(/* ... */);
const storyContext = await getStoryContext(page, context);
const violations = await getViolations(page, undefined, mergedOptions);
const shard: StoryShard = {
id: context.id,
title: storyContext.title,
name: storyContext.name,
violations: violations.map(/* ... */),
};
appendFileSync(SHARD_PATH, JSON.stringify(shard) + "\n", "utf8");
},
};
Désactiver les règles de niveau page
Une story Storybook rend un composant isolé dans un iframe. Trois règles d’axe ne s’appliquent pas dans ce contexte et génèrent du bruit :
const DISABLED_RULES_FOR_STORYBOOK = [
"region", // pas de <main> dans une story
"landmark-one-main", // pas de landmark englobant
"page-has-heading-one", // pas de <h1> dans une story
] as const;
Ces règles restent actives dans l’audit E2E où la page complète est rendue. C’est précisément ce qui rend les deux étages complémentaires.
Format de stockage : JSONL plutôt que JSON
Chaque story écrit son résultat dans un fichier storybook-shards.jsonl (une ligne JSON par story). C’est un choix technique important :
- Le test-runner Storybook (Jest sous le capot) n’expose pas de
globalTeardownasync fiable. Impossible de tout assembler en mémoire et d’écrire le résultat à la fin. - L’écriture synchrone ligne par ligne (
appendFileSync) est compatible avec l’exécution parallèle : chaque worker append à la même queue, le système de fichiers garantit l’atomicité de l’écriture d’une ligne courte. - Un script séparé (
scripts/a11y-storybook-consolidate.mjs) lit le JSONL après coup et produit le JSON consolidé.
Couche 3 : audit Playwright E2E
Un helper opt-in
Le helper auditPage est instrumenté dans les specs E2E aux moments significatifs (page chargée, modale ouverte, étape de wizard, etc.) :
import { auditPage } from "./helpers/a11y";
test('shows "More actions" button for a rule', async ({
extensionContext,
extensionId,
helpers,
}) => {
await helpers.addDomainRule({
label: "Jira/Atlassian",
domainFilter: "*.atlassian.net",
});
const page = await extensionContext.newPage();
await goToDomainRulesSection(page, extensionId);
await expect(/* ... */).toBeVisible();
await auditPage(page, "domain-rules-list-populated");
await page.close();
});
Le label (deuxième argument) sert à identifier le scénario dans le rapport final. Quand une violation apparaît, on sait dire “c’est dans domain-rules.spec.ts au moment où la liste est peuplée”.
Le helper est no-op par défaut
Point essentiel : auditPage ne fait rien tant que A11Y_ENABLED=true n’est pas dans l’environnement.
function isEnabled(): boolean {
return process.env.A11Y_ENABLED === "true";
}
Conséquence : les tests E2E classiques (en local, en watch, en debug) ne paient pas le coût de l’audit. Les développeurs peuvent saupoudrer auditPage() partout sans craindre de ralentir leur boucle de feedback.
En CI, A11Y_ENABLED=true est positionné sur les 3 shards E2E existants. Aucun run Playwright supplémentaire n’est nécessaire : l’audit a11y se greffe sur les tests fonctionnels.
Scoper l’audit à une zone précise
Quand on ouvre une modale, on veut souvent auditer uniquement la modale, pas le fond de page (qui peut avoir ses propres problèmes connus). Le helper accepte un sélecteur :
await auditPage(page, "restore-wizard-conflict-step", {
include: '[role="dialog"]',
});
Et pour désactiver une règle ponctuellement (toujours avec une justification en commentaire) :
// Désactivée car le composant est rendu hors contexte de page
await auditPage(page, "embedded-widget", { disableRules: ["region"] });
Consolidation par worker
Comme pour Storybook, chaque worker Playwright écrit son shard JSONL dans reports/a11y/e2e-shards/<pid>.jsonl. Un globalTeardown lit tous les shards et produit le JSON consolidé e2e-a11y.json.
export default async function globalTeardown(): Promise<void> {
if (!existsSync(SHARDS_DIR)) {
if (process.env.A11Y_ENABLED === "true") {
// Écrire un rapport vide pour ne pas casser le tooling aval
writeFileSync(OUTPUT_PATH, JSON.stringify(empty, null, 2), "utf8");
}
return;
}
const scenarios: ScenarioShard[] = [];
for (const file of readdirSync(SHARDS_DIR)) {
/* parse chaque ligne JSON, accumule les scenarios */
}
const summary = { critical: 0, serious: 0, moderate: 0, minor: 0 };
/* compte par sévérité */
writeFileSync(
OUTPUT_PATH,
JSON.stringify({ scenarios, summary }, null, 2),
"utf8"
);
}
Couche 4 : consolidation et publication
Un script unique pour le rapport humain
scripts/a11y-report.mjs lit les deux JSON consolidés (Storybook + E2E) et produit deux livrables :
summary.md: rapport markdown lisible, avec un tableau de comptes par sévérité, un top 10 des violations, et optionnellement un diff vs baseline.summary.json: version machine-readable pour d’éventuels outils en aval.
Le rapport markdown ressemble à ça :
# Accessibility report
Generated: 2026-05-03T08:42:11.234Z
## Summary
| Severity | Storybook | E2E | Total |
| -------- | --------: | --: | ----: |
| Critical | 0 | 0 | 0 |
| Serious | 2 | 0 | 2 |
| Moderate | 0 | 1 | 1 |
| Minor | 0 | 0 | 0 |
## Top 10 violations
1. **[color-contrast](https://...)** (serious, 2 occurrence(s))
Elements must meet minimum color contrast ratio thresholds
Examples: `components-core-session-sessioncard--default`
Diff vs baseline
Un fichier optionnel reports/a11y/baseline.json peut lister les règles déjà connues comme violées. Dans ce cas, le rapport ajoute une section “New rules vs baseline” qui liste uniquement les règles nouvellement apparues. Utile pour ne pas se noyer sous les violations historiques pendant qu’on traite la dette progressivement.
function renderBaselineDiff(currentIds) {
const baseline = readJsonIfExists(BASELINE_PATH);
if (!baseline || !Array.isArray(baseline.rules)) return null;
const baseSet = new Set(baseline.rules);
const added = Array.from(currentIds)
.filter(id => !baseSet.has(id))
.sort();
if (added.length === 0) return "_No new rules vs baseline._";
return added.map(id => `- \`${id}\``).join("\n");
}
Commentaire sticky de PR
Le job report du workflow GitHub Actions télécharge tous les artefacts (storybook + shards E2E des 3 jobs parallèles), les fusionne, lance le script de consolidation, puis publie le résultat en commentaire sticky :
- name: Post sticky a11y comment on PR
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('reports/a11y/summary.md', 'utf8');
const marker = '<!-- a11y-report -->';
const fullBody = `${marker}\n${body}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body && c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({ /* update */ });
} else {
await github.rest.issues.createComment({ /* create */ });
}
Le marker HTML <!-- a11y-report --> permet de retrouver le commentaire existant et de le mettre à jour à chaque push, plutôt que d’en créer un nouveau. Le résultat : un seul commentaire vivant par PR, toujours à jour.
Seuil d’échec configurable
Le seuil d’échec est piloté par la variable A11Y_FAIL_LEVEL qui accepte cinq valeurs : minor, moderate, serious (défaut), critical, none.
Concrètement, dans le workflow CI actuel le seuil est positionné à none sur les jobs a11y. La pipeline collecte les données mais ne fait jamais échouer le build à cause d’une violation. C’est un choix volontaire pour la phase d’amorçage : on veut que le rapport soit visible et discuté, pas qu’il bloque les merges tant que la baseline n’est pas stabilisée. Le verrou est prêt à être resserré quand on jugera utile.
Scripts pnpm
Les commandes exposées dans package.json :
"a11y:storybook:run": "storybook build && test-storybook",
"a11y:storybook:consolidate": "node scripts/a11y-storybook-consolidate.mjs",
"a11y:storybook": "pnpm a11y:storybook:run ; pnpm a11y:storybook:consolidate",
"a11y:e2e": "A11Y_ENABLED=true wxt build && A11Y_ENABLED=true xvfb-maybe playwright test",
"a11y:report": "node scripts/a11y-report.mjs",
"a11y": "pnpm a11y:storybook && pnpm a11y:e2e && pnpm a11y:report"
pnpm a11y enchaîne tout en local : Storybook, E2E, consolidation finale. Pratique avant de pousser une grosse PR.
Choix de design notables
Deux étages plutôt qu’un seul
J’ai hésité au début à ne faire qu’un seul étage Playwright. Mais Storybook offre quelque chose que le E2E ne peut pas : un audit exhaustif et déterministe de chaque composant, indépendamment des chemins fonctionnels qui le traversent. Si un composant SessionCard a 7 stories couvrant ses différents états (normal, dirty, disabled, dragging, etc.), l’audit Storybook les traverse toutes en quelques secondes. Reproduire ces 7 états via des scénarios E2E coûterait beaucoup plus cher.
Inversement, Storybook ne peut pas tester l’accessibilité d’un wizard à mi-parcours, ou d’une page après une action utilisateur réelle. C’est le rôle du E2E.
JSONL plutôt qu’agrégation en mémoire
Les deux étages utilisent le même pattern : chaque exécution unitaire (story ou audit E2E) écrit immédiatement son shard JSONL, et un script séparé consolide après coup.
Avantages :
- Robuste à la parallélisation (Storybook test-runner et Playwright workers).
- Robuste aux crashs : si un test fait planter le runner, on a quand même les résultats des tests qui ont tourné avant.
- Pas besoin d’un global teardown async qui n’est pas toujours fiable.
Pas de baseline obligatoire
La baseline est optionnelle. Si elle n’existe pas, le rapport montre toutes les violations actuelles. Si elle existe, il met en évidence les nouveautés. Cette flexibilité permet de démarrer sans créer une baseline parfaite, puis de l’introduire progressivement quand on a stabilisé un certain état.
Fail-safe partout
Tous les steps CI liés à a11y portent continue-on-error: true. La pipeline d’accessibilité n’a pas vocation à bloquer le pipeline principal en cas de panne d’infrastructure (artefact non téléchargeable, script qui plante). Si le rapport ne peut pas être généré, la PR continue de fonctionner, simplement sans commentaire a11y.
Conventions complémentaires
Le filet de sécurité automatique ne fait pas tout. Il est doublé par des règles internes appliquées à chaque nouveau composant, formalisées dans un skill réutilisé par les outils d’assistance :
- Icônes Lucide : toujours
aria-hidden="true". - Boutons icône only :
aria-label+titleobligatoires. - Préférer les primitives Radix (Dialog, Collapsible, Toolbar, RadioGroup) aux ARIA manuels.
- Les composants Radix Themes (Switch, IconButton, etc.) gèrent focus/clavier/ARIA nativement, ne pas les surcharger.
- Pour les états désactivés, préférer
aria-disabledàdisablednatif quand on veut conserver le focus pour afficher un tooltip explicatif.
Ces règles guident la production, le filet de sécurité vérifie le résultat. Les deux sont nécessaires.
Bilan
Le coût total du dispositif :
- Une dépendance dev (
axe-playwright) en plus de celles déjà présentes (@storybook/test-runner,@axe-core/playwright,@storybook/addon-a11y). - Un helper E2E de 100 lignes.
- Un hook Storybook test-runner de 80 lignes.
- Deux scripts de consolidation et un script de rapport (~300 lignes Node).
- Un job CI dédié pour Storybook, et trois étapes ajoutées au job report.
En contrepartie, à chaque PR, je vois en commentaire le tableau exact des violations, le top 10, et la liste des nouveautés vs baseline. Quand quelqu’un casse l’accessibilité d’un composant, ça se voit immédiatement, sur la bonne PR, avec le bon contexte.
Pour une extension qui revendique l’accessibilité comme valeur produit et dont l’auteur principal est lui-même malvoyant, c’est moins un luxe qu’un strict minimum.