Aller au contenu

Case study · aetherwx · QA harness

Un harnais QA déterministe qui a trouvé 9 bugs que l'œil ratait

Sur AetherWX (atlas météo/maritime, ma vitrine GIS), les layers cassaient en silence après chaque modif. Tests existants : fragmentés, manuels, zéro couverture vector, listes en dur. On a construit un harnais Node qui dérive son manifest depuis la source de vérité frontend, vérifie les données HTTP pixel-level, et teste le câblage UI via Playwright en lisant l'état MapLibre en live. Lancé une fois : 9 bugs d'opacity détectés, corrigés, validés en prod. Quatre mois plus tard il ne se lance plus à la main : une matrice de 26 combinaisons layer×style tourne toutes les 6 heures dans le cluster.

Le problème — layers silencieux

AetherWX affiche une quarantaine de layers activables : SST, vent GFS, vagues WW3, foudre Blitzortung, satellites NASA GIBS et EUMETSAT, radars nationaux (Allemagne, Pays-Bas, États-Unis, Canada), précipitations globales IMERG, navires AIS, alertes maritimes… Le tout pilotable par un slider temporel [-7j, +7j]. Visuellement, ça tient. Mais à chaque refactoring du composant principal globe.component.ts (8 000+ lignes), quelque chose cassait discrètement.

Symptôme concret : le slider d'opacité semblait marcher sur certains layers — l'UI répondait, la valeur changeait — mais la carte ne bougeait pas. Pas de 500, pas de console error. Le layer restait opaque. L'animation "fonctionnait 1 fois sur 100", selon l'humeur du timestamp GWC.

Les tests existants : 5 skills séparés, tous partiels. Aucune couverture des layers vector (WFS/API). Les listes de layers étaient codées en dur — donc drifteuses dès qu'on en ajoutait un. Confiance = zéro.

L'insight — 3 classes de bugs, 1 philosophie

Avant d'écrire la moindre ligne, on a posé le cadre. Il existe 3 classes de bugs distinctes sur une appli GIS comme celle-ci :

  • Données/rendu : le backend produit-il vraiment des tuiles non-vides ? GeoServer répond-il ? Le PNG GetMap contient-il quelque chose ou c'est un placeholder transparent ?
  • Câblage UI : le slider d'opacité que l'user bouge a-t-il un effet réel sur MapLibre — pas juste un changement de valeur dans un signal Angular, mais une mutation visible du layer rendu ?
  • Animation/temps : quand la time-bar avance, les layers WMS reçoivent-ils un &TIME= mis à jour, et les tuiles se rafraîchissent-elles ?

Et surtout, la règle fondatrice : une IA locale est le mauvais outil pour détecter les régressions. Un LLM peut halluciner un PASS sur un screenshot ambigu. Un vrai test doit être déterministe — HTTP + pixels + état MapLibre. L'IA garde sa place, mais seulement au triage des échecs après que le harnais a tranché : PASS ou FAIL. Jamais dans le verdict lui-même.

Ce qui a été construit — maritime-atlas/qa/

Trois modules Node dans maritime-atlas/qa/, lancés en séquence ou indépendamment :

1. Manifest auto-dérivé

Le premier problème avec les listes en dur, c'est qu'elles dérivent. On ajoute un layer côté Angular, on oublie de mettre à jour le test, et la régression passe inaperçue.

Solution : le manifest est dérivé au runtime depuis globe.component.ts — la source de vérité frontend. Un script parse le fichier TypeScript (regex ciblé sur LAYER_PROFILES et animatableLayers), extrait les IDs + types (raster/vector/cascade), et génère le JSON de contexte pour les checks suivants. Si un layer est ajouté dans le composant, le test le voit automatiquement la prochaine exécution.

2. Check données HTTP

Pour chaque layer raster, le check fait un GetMap WMS (ou GetFeatureInfo pour les vector) et inspecte la réponse à deux niveaux :

  • Le statut HTTP et le Content-Type — un ServiceException GeoServer sort en text/xml avec un 200, pas en 500 ; ça trompe curl naïf.
  • Pour les PNG, décodage IDAT : un tile "placeholder" (tuile vide que GeoServer sert quand il n'a pas de données pour ce timestamp) a une structure IDAT caractéristique — tous les pixels à RGBA(0,0,0,0). On lit les 4 premiers octets du chunk IDAT pour distinguer "tile réelle avec de la data" de "tile vide qui dit rien". Verdict : PASS (data réelle), BLANK (tuile vide mais valide — souvent normal hors bbox), UPSTREAM (le provider externe est down, pas notre bug), ou FAIL.
qa/check-data.ts — classification tuile typescript
type TileVerdict = "PASS" | "BLANK" | "UPSTREAM" | "FAIL" | "SKIP";

async function checkRasterTile(layer: LayerSpec): Promise<TileVerdict> {
  const url = buildGetMapUrl(layer, { time: layer.sampleTimestamp });
  const res = await fetch(url, { signal: AbortSignal.timeout(8000) });

  if (!res.ok) return "FAIL";
  const ct = res.headers.get("content-type") ?? "";
  if (ct.includes("xml") || ct.includes("text")) return "FAIL"; // ServiceException

  const buf = Buffer.from(await res.arrayBuffer());
  // Decode IDAT: si tous pixels alpha=0 → tuile placeholder
  const isBlank = isPngBlank(buf);
  return isBlank ? "BLANK" : "PASS";
}

3. Check câblage UI via Playwright

C'est la brique la plus délicate. Le principe : ouvrir la carte, activer un layer, bouger le slider d'opacité de 100% → 30%, et vérifier que l'opacité réelle du layer MapLibre a changé — pas juste la valeur affichée dans l'UI Angular.

Deux contraintes majeures :

  • Accès à l'état MapLibre sans faille de sécurité. Avant ce harnais, un flag window.__mapInstance avait été retiré après audit sécurité — il exposait trop. Solution : un hook read-only gelé exposé uniquement avec ?qa=1 dans l'URL, que le build de production inclut mais ne rend jamais accessible sans le paramètre. Il expose window.__qaReadOnly.getLayerOpacity(id) et window.__qaReadOnly.getActiveLayerIds(), rien d'autre.
  • ID du layer dérivé au runtime. L'approche naïve serait de hard-coder "quand on bouge le slider du layer sst-raster, son id MapLibre est aetherwx:sst-daily". Sauf que la fonction mapLibreLayerIds() peut avoir des branches manquantes — c'est exactement le bug qu'on cherche à détecter. On ne peut pas présupposer l'id. La solution : on demande à MapLibre lui-même quel layer a changé d'opacité entre l'avant et l'après slider.
qa/check-ui.ts — runtime-derived layer id typescript
// Snapshot des opacités AVANT le déplacement du slider
const before = await page.evaluate(() =>
  window.__qaReadOnly.getActiveLayerIds().map((id) => ({
    id,
    opacity: window.__qaReadOnly.getLayerOpacity(id),
  }))
);

// Simuler le glissement du slider d'opacité : 100 → 30
await page.locator(`[data-qa-slider="${layerKey}"]`).fill("30");
await page.waitForTimeout(200); // laisser le signal Angular propager

// Snapshot APRÈS
const after = await page.evaluate(() =>
  window.__qaReadOnly.getActiveLayerIds().map((id) => ({
    id,
    opacity: window.__qaReadOnly.getLayerOpacity(id),
  }))
);

// Quel layer a changé ? Si aucun → FAIL (câblage manquant)
const changed = after.filter((a) => {
  const b = before.find((b) => b.id === a.id);
  return b && Math.abs(a.opacity - b.opacity) > 0.05;
});

if (changed.length === 0) return { verdict: "FAIL", reason: "opacity unchanged in MapLibre" };
return { verdict: "PASS", changedLayer: changed[0].id };

Le payoff — 9 bugs d'un coup

Le harnais lancé pour la première fois contre la prod. Résultat : 9 FAIL sur le check UI opacité, tous le même symptôme — slider bouge, MapLibre ne change rien.

Cause racine identifiée en 10 minutes : la fonction mapLibreLayerIds(layerKey: string): string[] dans globe.component.ts avait des branches manquantes pour 9 layers. Ces layers passaient par un default: return [] silencieux — le slider Angular appelait setOpacity([]) sur zéro layers MapLibre. L'UI Angular répondait (signal mis à jour), MapLibre ne faisait rien, zéro erreur.

Un fix manuel à l'œil avait été fait le même jour sur certains layers évidents — il en avait corrigé 3, raté les 9 autres (trop de branches, trop similaires). Le harnais a trouvé tous les 9 d'un coup.

Fix : ajout des branches manquantes dans mapLibreLayerIds(). Build → tag → deploy (2 rollouts car un premier tag avait une coquille dans le gitops). Re-run du harnais contre prod : FAIL=0.

La suite — une matrice qui ne peut pas dériver

Le harnais ci-dessus se lançait à la main. Quatre mois plus tard, une régression de trop a fait poser une règle explicite par Sylvain : « une correction d'un layer ne doit plus jamais casser un autre layer ». Le contexte : un correctif sur les styles avait réparé les palettes de vent et de vagues, et cassé au passage les isolignes — sans que personne ne le voie pendant deux jours, parce qu'on ne regardait que le layer qu'on venait de corriger.

La réponse est une matrice de 26 combinaisons layer×style, avec un principe repris du manifest auto-dérivé : elle n'est écrite nulle part à la main.

Générée en parsant le frontend

Un script Python lit frontend/src/app/components/map-view/map-layers-constants.ts — le fichier que la carte utilise réellement pour construire ses requêtes — et en sort une ligne par combinaison, triée, dans un format stable :

scripts/wms-smoke-matrix.py — extrait de sortie (26 lignes) text
aetherwx-sat|sat-imerg-precip||gibs30|satna
aetherwx|radar-geomet-ca||cascade|na
aetherwx|radar-iem-us||cascade|na
aetherwx|sst-daily|aetherwx:sst-contours-only|daily|europe
aetherwx|sst-daily|sst-direct|daily|europe
aetherwx|wind-speed|aetherwx:wind-speed-contours-only|iso|europe
aetherwx|wind-speed||iso|europe

# workspace | layer | STYLES exact (vide = style par defaut) | classe de temps | bbox

Les deux dernières colonnes portent l'essentiel du savoir métier. La classe de temps dit quel TIME a un sens pour cette source : le jour courant pour la SST quotidienne, J-2 pour les produits satellite GIBS, l'heure pleine moins deux pour les radars cascadés (le temps que l'upstream publie), l'heure moins neuf pour IMERG dont l'ingestion accuse environ sept heures de retard. La bbox dit où la donnée existe — demander un radar NEXRAD sur l'Europe rend une tuile vide, ce qui est correct et ne doit pas compter comme un échec.

Deux détails rendent le résultat non négociable. D'abord, une ServiceException GeoServer sort en Content-Type: text/xml avec un statut 200 : on la classe FAIL, jamais « réponse reçue, donc ça marche ». Ensuite, chaque requête porte un paramètre de cache-bust pour taper le rendu et pas le cache de tuiles — sinon on teste la mémoire de GeoWebCache, pas GeoServer.

Le canary — toutes les 6 heures, dans le cluster

La matrice générée est aussi gelée dans le dépôt GitOps, à côté d'un CronJob qui la rejoue toutes les 6 heures depuis l'intérieur du cluster. Deux boucles de sécurité s'imbriquent :

  • Le canary vérifie les layers. Un job en échec est conservé, avec la combinaison fautive et l'exception GeoServer dans ses logs — le diagnostic est un kubectl logs, pas une reproduction manuelle.
  • Le harnais vérifie le canary. À chaque exécution locale, la matrice est régénérée depuis le frontend puis diffée contre la copie gelée du dépôt. Une divergence est un échec. Autrement dit, la copie in-cluster ne peut pas pourrir en silence quand on ajoute un layer — ce qui était exactement le défaut des listes en dur au départ.

Résultat : 26/26 PASS, 0 BLANK, 0 FAIL pour la première fois le 08/09/2026. Ce chiffre est devenu la porte d'entrée du chantier suivant — la migration de GeoServer 2.28 vers 3.0.1 n'a été déclarée terminée qu'après avoir re-produit la même matrice pleine sur le nouveau moteur.

Le test d'animation, et sa règle du bruit

En parallèle du canary, un test E2E dédié à l'animation temporelle valide les invariants du contrat : l'animation itère strictement les timestamps réels du layer maître (pas un pas arbitraire), la tuile rendue change vraiment entre deux positions du curseur, le second passage sort du cache, l'arrêt nettoie complètement, et le pas affiché est le pas natif de la source. Six invariants au contrat, cinq vérifiables dans un run nominal — le sixième, « échouer bruyamment », ne s'observe qu'en cassant volontairement une pré-condition.

Ce test a produit le faux positif le plus instructif de la série. Après la migration GeoServer, l'invariant de cache est sorti en échec : la même tuile demandée deux fois répondait MISS. Sauf que la règle de cache Cloudflare posée devant les tuiles fige la première réponse de l'origine — en-têtes GeoWebCache compris — et la rejoue ensuite. On lisait un en-tête vieux de plusieurs minutes. Vérification faite en court-circuitant le CDN : l'origine servait bien du cache, sur chacun des trois pods, preuve que le stockage S3 partagé fonctionnait. Règle ajoutée au harnais : si cf-cache-status vaut HIT, aucun en-tête applicatif de la réponse n'est concluant.

La méta-leçon — le harnais s'est auto-corrigé

La partie la plus intéressante n'est pas les 9 bugs. C'est ce qui s'est passé avec la première version du test lui-même.

La v1 présupposait l'id MapLibre du layer testé — elle hard-codait "aetherwx:sst-daily" comme id attendu après glissement du slider SST. Résultat : un faux positif. Le test déclarait PASS pour SST alors que la fonction mapLibreLayerIds() renvoyait en fait ["aetherwx:sst-raster"] (le vrai nom du layer MapLibre). Le slider touchait bien le bon layer, mais le test comparait le mauvais id — il validait l'absence de changement sur un layer qui n'existait pas.

Détecté en live en croisant le résultat du check avec un screenshot Playwright : le layer visuel avait changé, le test disait PASS pour la mauvaise raison. Rendu robuste en passant au pattern "quel layer a changé" (voir le bloc de code ci-dessus). Revalidé. Le test a ensuite correctement catchant les 9 FAIL réels.

Principe réaffirmé : un test qu'on n'a jamais vu échouer ne vaut rien. Et corollaire : un test qui passe pour la mauvaise raison est pire qu'un test absent — il donne une fausse confiance.

Pourquoi ça compte — ingénierie de fiabilité démontrable

Ce harnais n'est pas un gadget. C'est une pratique d'ingénierie de fiabilité sur une vraie appli GIS complexe, avec les contraintes réelles d'une telle stack :

  • Manifest auto-dérivé → la suite ne peut pas dériver silencieusement de la source de vérité frontend. Un layer ajouté est automatiquement testé.
  • Détection IDAT PNG → on distingue "données présentes" de "tuile placeholder" sans regarder l'image à l'œil. Déterministe, scriptable en CI.
  • Hook read-only ?qa=1 → accès à l'état interne MapLibre sans rouvrir une surface d'attaque. Pattern réutilisable sur toute appli Angular+MapLibre.
  • Runtime-derived layer id → le test ne présuppose pas l'implémentation interne. Il observe le comportement. C'est la différence entre un test qui valide le contrat et un test qui valide le code source.
  • Le test tourne sans qu'on le lance → un harnais qu'il faut penser à exécuter finit par ne plus être exécuté. Le CronJob canary toutes les 6 heures déplace la question de « est-ce que quelqu'un a testé ? » à « est-ce qu'un job est en échec ? ».
  • Philosophie IA au triage, pas au verdict → le harnais sort une liste JSON de FAIL avec contexte (layer, verdict, reason, screenshot). Claude peut analyser la liste et proposer les branches mapLibreLayerIds() manquantes. Mais c'est le harnais déterministe qui a le dernier mot sur PASS/FAIL.

C'est exactement le genre de pipeline QA qu'on peut démontrer en entretien sur un vrai projet : une suite E2E avec une philosophie claire, un résultat mesurable (9 bugs trouvés, 0 faux négatif après correction, puis 26/26 combinaisons vertes tenues à travers un changement de version majeure de GeoServer), et une architecture qui résiste à la dérive.

Le process avec Claude Code

Le harnais a été construit en une session de pair-programming avec Claude Code. Le découpage en 3 modules indépendants est venu dès l'analyse — un agent background a pu scaffolder le check données pendant qu'on écrivait le check UI en main.

Le moment le plus utile a été le diagnostic du faux positif SST : Claude a proposé le pattern "runtime-derived" immédiatement, sans qu'il soit nécessaire de décrire le problème en détail. Le contexte (screenshot Playwright + diff JSON avant/après) était suffisant. C'est un bon exemple de la niche où l'IA est réellement utile : pas pour décider si le test passe, mais pour suggérer comment le rendre plus robuste une fois qu'on a identifié sa faiblesse.

/save en fin de session a capturé les patterns réutilisables : hook read-only ?qa=1, IDAT blank detection, runtime-derived layer id via delta snapshot.