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. FAIL=0.

Le problème — layers silencieux

AetherWX affiche une quinzaine de layers simultanément : SST, vent GFS, vagues WW3, foudre Blitzortung, satellites GOES/Meteosat, radar pluie RainViewer, 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 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.
  • 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), 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.