Aller au contenu
visiteur@esprit-vorace:~/posts$ ls -lah
Retour
» cat filet-securite-a11y.md

# Mettre en place un filet de sécurité accessibilité dans une extension de navigateur

11 min de lecture

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 :

  1. Détecter les régressions automatiquement, sur chaque pull request, sans dépendre de la mémoire ou de la bonne volonté.
  2. Distinguer ce qui se passe au niveau composant isolé de ce qui se passe à l’échelle de la page complète.
  3. 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 :

  1. Lint statique : eslint-plugin-jsx-a11y (preset recommended), appliqué uniquement aux fichiers src/**/*.tsx. Première barrière contre les erreurs évidentes (alt manquant, role invalide, etc.).
  2. Audit Storybook : axe-core injecté dans chaque story via @storybook/test-runner. Mesure l’accessibilité des composants en isolation.
  3. 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.
  4. 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 :

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 :

  1. 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.
  2. 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 :

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 :

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 :

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.


Partagez cet article sur :

Article suivant
jscpd, ou comment j'ai nettoyé le code dupliqué que l'IA m'avait laissé