metalfrom.eu/README-CI.md
Nicolas Fryder f14060720b
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(admin): refonte du panneau par intention, vue Localisations, tests e2e
Le panneau était découpé par table SQL, pas par question que se pose l'admin.
Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen
de voir ce qui coince réellement.

Redécoupage — un onglet = une question
- Pilotage      : est-ce que ça tourne ? (fusionne l'ancien « Actions »)
- Groupes       : trouver et corriger
- Localisations : qu'est-ce qui coince dans le géocodage ?  ← NOUVEAU
- Journal       : que s'est-il passé ?
- LLM & coûts   : combien ça coûte ?

Pilotage : chaque chiffre problématique porte son action
- Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état
- Alertes actionnables : « 12 localisations en erreur » + [Examiner] +
  [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet
- Déclencheurs de traitements inline, opérations destructives repliées
- L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais
  consultés) est retiré

Localisations : la vue qui manquait totalement
L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen
de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des
milliers d'appels Geoapify/Groq facturés.
- GET  /admin/api/locations          liste filtrable (statut, lieu, groupe, pays)
- POST /admin/api/locations/:id/requeue     relance UNE ligne
- PATCH /admin/api/locations/:id            saisie manuelle des coordonnées
- Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle

Groupes : recherche d'abord
Une barre de recherche et des filtres rapides en chips remplacent les 8 champs
texte ; les filtres avancés sont repliés.

Accessibilité
- Lignes de tableau activables au clavier (role=button, tabindex, Entrée)
- Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine
- Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1).
  Les messages d'erreur étaient difficiles à lire. Ajout de --err-text /
  --ok-text (~6,5:1) pour les usages en couleur de texte.
  Détecté par les nouveaux tests axe sur les vues rendues.
- Statuts affichés en français au lieu des valeurs brutes de la base

Tests
- 36 tests API sur les trois nouveaux endpoints (allowlist de statuts,
  bornes des coordonnées, audit, 404)
- 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une
  topologie identique à la production (statique servi + /admin/* proxifié)
- Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la
  coquille vide du dashboard, tout étant construit en JavaScript
- e2e branché sur le hook pre-push, pas sur `check` : la boucle de
  développement reste à 6 s, le push coûte 28 s

Correction trouvée par les tests
Le routeur ne séparait pas la query string du nom de vue :
« #/locations?status=error » ne correspondait à aucun alias et retombait sur
Pilotage — les liens des alertes ne fonctionnaient pas.

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

155 lines
8.2 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 + 444 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 (1511 mutants) | 3 min 40 | **12 s** |
| `npm run test:e2e` | 53 parcours Playwright (dashboard admin) | 22 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) / 65 % (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) : **65 %**. 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.
## 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.