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.
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.