metalfrom.eu/README-CI.md
Nicolas Fryder c30656544c
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
fix(admin): passe de debug guidée par la mutation — 4 bugs, mutation 64 → 68 %
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.

Bugs trouvés et corrigés

1. crawler_pending détruit silencieusement (backend)
   Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
   seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
   supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
   ses conflits de genre, de pays et de localisation, sans trace.
   Corrigé en suivant la convention de /resolve-conflict : retrait des clés
   arbitrées (`crawler_pending - ARRAY[...]`).

2. Filtre genre non borné (backend)
   q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
   ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
   country et status sont bornés au passage, par cohérence.

3. Chips inutilisables après une alerte (frontend)
   Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
   alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
   revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
   (replaceState, qui ne déclenche pas hashchange).

4. Minuteur d'auto-refresh orphelin (frontend)
   Le minuteur était armé APRÈS le chargement des données. Quitter la vue
   pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
   Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
   autre onglet, indéfiniment. Résolu par un jeton de rendu.

Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.

Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.

Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js  : les 3 drapeaux × 2 polarités × présence/absence,
  bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js    : effet du PATCH sur crawler_pending, casts par type,
  allowlist, cohérence avec resolve-conflict
- tools.spec.js        : tri, pagination, arbitrage de conflit, annulations,
  filtres LLM, cycle de vie des vues

Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.

Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:42:52 +02:00

168 lines
8.9 KiB
Markdown

# Qualité, tests et CI/CD
## Le modèle en une phrase
Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu,
`main` → metalfrom.eu). Rien ne s'interpose. La porte de qualité est donc
**locale**, dans le hook `pre-push`.
```
git push ──▶ [pre-push : npm run check + test:e2e] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
↑ 6 s + 22 s, bloquant
```
Deux granularités volontaires : `check` (6 s) tourne pendant qu'on code, les
parcours Playwright (22 s) seulement au moment de pousser. Mettre l'e2e dans
`check` ferait passer la boucle de 6 à 22 s pour un gain marginal — la plupart
des régressions tombent déjà dans les tests unitaires.
## Installation (une fois par clone)
```bash
npm install
npm run hooks:install # active .githooks/pre-push
pip install -r requirements-dev.txt
```
## Commandes et temps mesurés
| Commande | Ce que ça fait | Froid | Incrémental |
|---|---|---|---|
| `npm run check` | **La porte** : ESLint + tsc + Ruff + 572 tests | **6 s** | — |
| `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — |
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s |
| `npm run test:mutation:full` | Mutation, toute l'API (2159 mutants) | ~5 min | **8 s** |
| `npm run test:e2e` | 82 parcours Playwright (dashboard admin) | 27 s | — |
| `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — |
| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min |
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
### Où passe le temps
La commande de la boucle de développement, c'est `npm run check` : **6 s**,
tests compris. C'est elle qui tourne cent fois par jour, et elle est passée de
17 s à 6 s par trois mesures successives :
| Levier | Gain |
|---|---|
| Les 5 vérifications en parallèle au lieu de `&&` (elles sont indépendantes, et l'enchaînement npm coûtait 7 démarrages) | 14 s → 6 s |
| Cache ESLint (`--cache`) et compilation incrémentale tsc | 16 s → 14 s |
| Chemin critique restant : vitest 4,9 s, dont ~2 s de mise en place jsdom | — |
La mutation n'est pas une commande de boucle courte :
- `test:mutation` (352 mutants) : 33 s à froid, ~5 s ensuite.
- `test:mutation:full` (1511 mutants) : 3 min 40 **une seule fois**, puis
**12 s** après une édition. Le fichier incrémental vit dans `reports/`, qui
est gitignoré : un clone neuf paie donc le run complet une fois.
**Ce qui a été essayé et rejeté sur mesure** (les intuitions se sont trompées
plus d'une fois — tout est re-mesurable) :
| Piste | Résultat |
|---|---|
| Découper la mutation en 5 processus Stryker parallèles | **262 s contre 235 s** : chaque processus repaie son bac à sable et son dry-run. Retiré. |
| Monter `concurrency` de 6 à 16 | Aucun effet : ~8,5 mutants/s dans tous les cas. Le débit est limité par l'orchestration mono-processus de Stryker, pas par le CPU. |
| Séparer les tests API / frontend en deux processus vitest | Aucun gain : 4,9 s dans les deux cas, jsdom domine. |
| Pool vitest `threads` sur la **suite complète** | 18,8 s contre 3,5 s (jsdom pénalisé). Défaut conservé. |
| Pool vitest `threads` sur la **config de mutation** (sans jsdom) | Overhead d'amorçage 12 844 ms → **1 592 ms**. Adopté. |
**Ce qui n'a délibérément pas été fait** : réduire le périmètre muté. Descendre
sous ~1500 mutants sur l'API reviendrait à ne plus mesurer grand-chose, et un
score flatteur obtenu en retirant les mutants gênants est pire que pas de score.
Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé.
## Ce que couvrent les tests
| Batterie | Où | Approche |
|---|---|---|
| **Unitaires** | `apps/api/test/validate.test.js` | Validation pure : bbox, limit/offset, coordonnées, années, recherche |
| **Routes / intégration** | `publicRoutes*.test.js`, `adminRoutes*.test.js` | `fastify.inject()` + faux pool. Gating d'auth, allowlists de colonnes, forme du SQL, transactions |
| **Sécurité** | `adminAuth.test.js`, `rateLimit.test.js` | JWT forgé, `alg:none`, expiration, anti-énumération, verrouillage de compte, plafonds de débit par IP et par jeton |
| **Annulation** | `cancellation.test.js` + `apps/crawler/tests/test_cancellation.py` | Les deux moitiés du protocole coopératif |
| **Accessibilité** | `apps/web/test/a11y.test.js` | axe-core + jsdom sur le HTML statique |
| **Frontend** | `apps/web/test/pure.test.js` | Filtrage, tri, échappement HTML |
| **Méta** | `apps/api/test/harness.test.js` | Vérifie le faux pool lui-même |
| **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation |
| **Parcours (e2e)** | `apps/admin/test/e2e/admin.spec.js` | Playwright sur la VRAIE app Fastify + faux pool : connexion, recherche, édition, déblocage d'une localisation, annulation d'un traitement |
| **a11y des vues rendues** | `apps/admin/test/e2e/a11y.spec.js` | axe-core dans un vrai navigateur — contraste inclus, ce que jsdom ne sait pas calculer |
| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 68 % (API complète) |
## Principe des tests
**Aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.**
Deux choix structurants côté API :
1. **Une app Fastify par fichier de test, pas par cas.** `buildServer()` coûte
14 ms ; à raison d'une construction par `it()`, ces 14 ms étaient multipliés
par des milliers d'exécutions en mutation. Les tests partagent une app et
reprogramment un faux pool via `pool.reset()` (`test/helpers/testApp.js`).
2. **Plafonds de débit paramétrables.** Conséquence du point 1 : une app
partagée accumule l'état du limiteur. Les valeurs réelles de production sont
donc vérifiées séparément dans `rateLimit.test.js`, pour que cette
paramétrisation ne crée pas d'angle mort sur une protection de sécurité.
## Pourquoi deux profils de mutation
La mutation prouve qu'un test **échoue** quand le code casse — la couverture ne
dit que « cette ligne a été exécutée ».
- `test:mutation` (validate.js, adminAuth.js) : **82 %**. Logique pure, score
interprétable, assez rapide pour être lancé souvent.
- `test:mutation:full` (+ adminRoutes.js, app.js) : **68 %**. Les handlers de
route plafonnent structurellement plus bas — beaucoup de mutants portent sur
des messages de log ou des branches `catch` dont l'observable exact n'a pas
d'importance. Utile en audit, pas en boucle courte.
Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
objectifs.
### Ce que les survivants ont révélé
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que **tous les filtres de `/admin/api/bands` étaient non
testés** (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Les tests écrits pour combler ce
trou ont mis au jour un bug réel : `genre` n'était pas tronqué, contrairement
aux autres champs de recherche.
Le score est passé de 64 % à 68 % en écrivant des tests, pas en retirant des
mutants. `reports/mutation-full.json` contient le détail exploitable ; le
script de tri utilisé est décrit dans l'historique Git.
## Les parcours e2e
`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un
serveur HTTP sert les fichiers statiques et proxifie `/admin/*` vers la VRAIE
application Fastify (comme `apps/admin/nginx.conf`), avec un faux pool à la
place de Postgres.
Sont donc réellement exercés : le routage Fastify, les cookies, le JWT, bcrypt,
la validation des entrées et la génération SQL. Ne le sont pas : la validité du
SQL pour Postgres et le comportement de PostGIS — affaire de tests
d'intégration, pas de tests d'interface.
Démarrage ~1 s, aucun conteneur. Une session est enregistrée une fois
(`auth.setup.js`) et partagée : sans ça, chacun des 53 scénarios repayait un
aller-retour de connexion complet. 4 workers est l'optimum mesuré (8 et 12 sont
plus lents — contention au démarrage des navigateurs).
### Ce qui n'est pas couvert
- Règles a11y exigeant un moteur de rendu sur le **site public** : le test jsdom
ne calcule pas les styles. Le dashboard admin, lui, est couvert par axe dans
un vrai navigateur.
- `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`.
- Crawler et workers de géocodage : seuls le parseur et l'annulation sont
couverts, le reste est de l'I/O réseau et base.
- `apps/web/site/pure.js` est testé mais **exclu de la mutation** : il est
chargé via `fs` + `vm` (comme le fait le navigateur), donc l'instrumentation
Stryker ne l'atteindrait pas et afficherait un score faussement parfait.
## Forgejo Actions
`.forgejo/workflows/ci.yml` existe mais **ne tourne pas** : aucun runner
`act_runner` n'est enregistré sur le VPS. Le fichier est prêt si tu en ajoutes
un. Il est volontairement informatif — il ne bloque pas le déploiement.