Conteneurisation Docker + tests de release (container-test, mode --release)

Conteneurisation (site 100 % statique — l'image ne détient AUCUNE donnée,
vie privée identique) :
- Dockerfile : nginxinc/nginx-unprivileged:alpine (uid 101, port 8080,
  pas de root), healthcheck wget intégré, labels OCI
- nginx.conf : no-cache systématique (cohérence du jeu de fichiers à
  chaque mise à jour d'image, coût nul : ~600 Ko + gzip sur l'asset PK),
  gzip, en-têtes de sécurité, deny des dotfiles
- .dockerignore : runtime uniquement (docs/ inclus — dialog changelog) ;
  local-test-data exclu en filet de sécurité
- docker-compose.yml : read_only + tmpfs (/var/cache/nginx, /run, /tmp —
  les temporaires de nginx-unprivileged, constat au premier test réel)
  + no-new-privileges

Nouveaux tests avant release :
- scripts/container-test.sh : build + run DURCI (config compose) +
  healthcheck healthy + endpoints 200 + headers (no-cache/nosniff/DENY/
  no-referrer) + gzip réel + MIME strict des modules ES + fichiers cachés
  non servis + image propre (pas de node_modules/npm) + docs/ embarqué +
  E2E playwright complet CONTRE LE CONTENEUR (HRT_E2E_BASE)
- scripts/e2e.mjs : mode externe HRT_E2E_BASE (pilote un site déjà déployé
  — conteneur inclus, serveur local non démarré)
- scripts/check.sh --release : supplée les 9 vérifications (version ↔
  changelog ↔ tag ↔ arbre propre + test conteneur si daemon)

Runtime Docker local installé pour le CI-like (colima 2 CPU / 2 Go) :
premier build + run réel = 2 problèmes testés et corrigés (tmpfs /tmp,
key case header_json curl). CONTAINER TEST OK en configuration durcie.
This commit is contained in:
Siphonight 2026-09-08 21:07:23 +02:00
parent 491efef8eb
commit a0245e72c2
10 changed files with 646 additions and 16 deletions

27
.dockerignore Normal file
View File

@ -0,0 +1,27 @@
# ─────────────────────────────────────────────────────────────────────────────
# .dockerignore — ce qui n'entre PAS dans l'image de déploiement.
#
# L'image ne sert que le RUNTIME : app (index.html, css/, js/, assets/),
# docs/ (⚠️ requis — le dialog « Nouveautés » fetch docs/CHANGELOG.md),
# LICENSE/README. Tout le reste est exclu :
# - .git, node_modules, package-lock.json : jamais dans une image ;
# - tests/, scripts/ : outils de dev — inutiles au runtime (et l'image
# devient plus propre à auditer : `docker run … find /usr/share/nginx/html`);
# - local-test-data/ : DOUBLON DE SÉCURITÉ — exports de santé réels jamais
# versionnés (cf docs/DEVELOPPEMENT.md §3) ; s'ils existaient par erreur
# à la racine du contexte de build, ils n'iraient JAMAIS dans l'image ;
# - fichiers de config du conteneur exclus aussi (Dockerfile/compose sont
# des instructions, pas des fichiers à servir).
# ─────────────────────────────────────────────────────────────────────────────
.git
.gitignore
.dockerignore
Dockerfile
docker-compose.yml
node_modules
package-lock.json
tests
scripts
local-test-data
.DS_Store
Thumbs.db

41
Dockerfile Normal file
View File

@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────────
# HormoneTrack Web — image de déploiement Docker (site statique, 100 % local).
#
# POURQUOI C'EST SIMPLE : l'app n'a AUCUN build (ES modules natifs) et AUCUN
# composant serveur — « conteneuriser » = servir les fichiers avec nginx.
# Le conteneur ne détient AUCUNE donnée : tout vit dans le localStorage des
# navigateurs clients (le serveur ne fait que distribuer des fichiers, cf
# docs/DEVELOPPEMENT.md §10 — la vie privée est inchangée).
#
# Choix d'image : `nginx-unprivileged` (utilisateur 101, port 8080, écritures
# limitées à /tmp) — meilleure pratique conteneur (pas de root), même image
# que pour un self-host NAS/VPS. Alpine = ~50 Mo décompressé.
#
# Build : docker build -t hormonetrack-web .
# Run : docker run -p 8080:8080 hormonetrack-web (ou docker compose up -d)
# Cf docs/DEVELOPPEMENT.md §10 « Déploiement ».
# ─────────────────────────────────────────────────────────────────────────────
FROM nginxinc/nginx-unprivileged:alpine
# ── Métadonnées OCI (traçabilité du conteneur déployé) ───────────────────────
LABEL org.opencontainers.image.title="HormoneTrack Web" \
org.opencontainers.image.description="Suivi de thérapie hormonale — app web 100 % locale (site statique). Portage navigateur de l'app Android." \
org.opencontainers.image.source="https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web" \
org.opencontainers.image.licenses="GPL-3.0"
# ── Config nginx spécifique (port non-privilégié, no-cache, gzip, sécurité) ──
COPY nginx.conf /etc/nginx/conf.d/default.conf
# ── Fichiers du site ────────────────────────────────────────────────────────
# Tout le dépôt SAUF ce qui n'est pas runtime (tests, scripts, npm) — cf
# .dockerignore. ⚠️ docs/ DOIT rester dans l'image : le dialog « Nouveautés »
# fetch docs/CHANGELOG.md au démarrage.
COPY . /usr/share/nginx/html/
# Le site est statique : le healthcheck synthétique vérifie que nginx répond
# (busybox wget est présent dans l'image alpine).
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
# Port non privilégié (l'image unprivileged n'a pas le droit de binde <1024)
EXPOSE 8080

View File

@ -57,6 +57,27 @@ server {
}
```
### Avec Docker (self-host NAS/VPS)
L'image ne fait **que servir les fichiers** : aucune donnée dedans, la vie
privée est identique (les données vivent dans les navigateurs). Build et
exécution :
```bash
docker compose up -d # build + run → http://<hôte>:8080/
docker compose up -d --build # rebuild après mise à jour du dépôt
```
- Image **non privilégiée** (`nginx-unprivileged`, utilisateur 101, port
8080), filesystem en **lecture seule** (composé prêt pour le NAS),
healthcheck intégré, gzip sur l'asset PK (550 Ko → ~150 Ko).
- Headers `no-cache` : une mise à jour d'image est prise en compte au
rechargement suivant, sans code périmé chez les clients.
- Validation en 3 commandes sur l'hôte qui a Docker (cf
[docs/DEVELOPPEMENT.md §10](docs/DEVELOPPEMENT.md)) : build, run, puis
`HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs` — le même test
E2E que le CI pilote le site SERVI PAR LE CONTENEUR.
Aucun build, aucune variable d'environnement, aucun composant serveur : les
données de chaque personne vivent **dans SON navigateur**, jamais sur la
machine qui sert les fichiers.

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
# ─────────────────────────────────────────────────────────────────────────────
# docker-compose — un commandement pour tourner HormoneTrack Web.
#
# docker compose up -d → http://<hôte>:8080/
# docker compose up -d --build (rebuild après mise à jour du dépôt)
#
# Le conteneur ne détient AUCUNE donnée (site statique) : le volume n'est
# pas nécessaire, la persistance vit dans les navigateurs. Pas de variable
# d'environnement, pas de volume — la config = le code servi.
# ─────────────────────────────────────────────────────────────────────────────
services:
web:
build: .
# Nom d'image stable : retaguer par version à chaque release sync si
# tu veux garder un historique (docker tag hormonetrack-web hormonetrack-web:vX.Y.Z)
image: hormonetrack-web:latest
container_name: hormonetrack-web
ports:
- "8080:8080" # port non privilégié de l'image unprivileged nginx
restart: unless-stopped
# ── Durcissement (site statique : AUCUNE écriture nécessaire) ───────────
# Filesystem en lecture seule + tmpfs pour les seuls dirs que nginx
# touche : l'image unprivileged place ses temporaires dans /tmp
# (proxy_temp, client_temp, pid) — sans ce tmpfs, nginx émerge
# « mkdir /tmp/proxy_temp failed (30: Read-only file system) » au
# démarrage avec read_only (constaté au premier container-test 8 sept.).
read_only: true
tmpfs:
- /var/cache/nginx
- /run
- /tmp
security_opt:
- no-new-privileges:true

View File

@ -521,12 +521,56 @@ push initial v1.2.3, cf doc Android §16.)
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
pins identiques) — jamais un seul côté.
### Déploiement du site (quand décidé)
### Déploiement — Docker (recommandé pour self-host)
- **copier le dépôt** (ou `git archive vX.Y.Z`) sur n'importe quel
hébergeur statique ; rien à compiler ;
- optionnel : exclure `tests/`, `scripts/`, `docs/` du document root
(inoffensifs mais inutiles en prod).
L'app étant un site **100 % statique**, la conteneurisation est triviale :
l'image ajoute UNIQUEMENT un serveur web. Points structurants :
| Aspect | Décision | Pourquoi |
|---|---|---|
| Image de base | `nginxinc/nginx-unprivileged:alpine` | run en uid 101 (pas de root), port 8080, writes limités à /tmp — meilleure pratique conteneur, ~50 Mo |
| Données | **AUCUNE dans l'image** (`.dockerignore` exclut aussi `local-test-data` en filet de sécurité) | le conteneur distribue des fichiers ; la vie privée est identique (localStorage clients) — cf §3/§5 |
| Contenu servi | index.html + css/js/assets + **docs/** (⚠️ le dialog « Nouveautés » fetch `docs/CHANGELOG.md` au démarrage) | pas de `tests/`, `scripts/`, npm dans l'image (audit facile : `find /usr/share/nginx/html`) |
| Cache | `no-cache, must-revalidate` sur TOUT | l'app = un jeu de fichiers qui doivent rester COHÉRENTS entre eux ; un JS périmé au moment d'une mise à jour = état incohérent. Total ≈ 600 Ko + gzip sur l'asset PK (550 Ko → ~150 Ko) : coût nul |
| Ports | 8080 (non privilégié) | l'image unprivileged ne peut pas binde <1024 |
| Durcissement | `read_only: true` + tmpfs `/var/cache/nginx` + `/run`, `no-new-privileges` | site statique : AUCUNE écriture nécessaire ; si un hôte compose refuse les tmpfs, retirer les deux lignes |
| Healthcheck | busybox `wget` sur `/` (30 s/3 s) | intégré au Dockerfile — orchestrateurs (compose/NAS) savent si le site répond |
**Fichiers** : `Dockerfile`, `nginx.conf` (server block remplacé),
`.dockerignore` (exclusions documentées — `docs/` reste inclus),
`docker-compose.yml` (durcissement inclus).
```bash
# ── Build + run ──
docker compose up -d # build + run → http://<hôte>:8080/
docker compose up -d --build # rebuild après git pull (mise à jour = 30 s)
docker tag hormonetrack-web:latest hormonetrack-web:vX.Y.Z # garder un historique d'images par release sync
```
**Valider le conteneur avec le MÊME test E2E que le CI** (le script pilote
n'importe quel serveur via `HRT_E2E_BASE` — à lancer sur l'hôte qui a
Docker ET exécute `npm i && npx playwright install firefox` une fois, cf
§8) :
```bash
docker compose up -d --build
HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs
# → les mêmes ~30 assertions (rendu, canvas peint pixel par pixel,
# navigation, écran changelog, 0 erreur console) contre le site SERVI
# PAR LE CONTENEUR — un bug nginx/MIME casse l'app, le test le voit.
```
**Fichiers servis vérifiés** : `/`, `/css/style.css`, `/assets/pk_profiles.json`,
`/js/ui/app.js`, `/docs/CHANGELOG.md` — les mêmes que le smoke HTTP de
`check.sh --with-serve`.
**Statut (8 sept. 2026)** : fichiers + docs prêts et relus — build NON
exécuté localement (aucun daemon Docker sur la machine de dev, CLI seule) ;
la configuration compose (`read_only`/tmpfs) est le pattern standard de
nginx-unprivileged, à valider au premier `docker compose up` sur l'hôte
cible. Si le durcissement pose problème sur ton NAS : retirer
`read_only`/`tmpfs`/`security_opt` du compose (aucune perte de sécurité
cruciale — le conteneur ne détient rien, cf plus haut).
## 11. Bugs potentiels évités pendant le portage

58
nginx.conf Normal file
View File

@ -0,0 +1,58 @@
# ─────────────────────────────────────────────────────────────────────────────
# Config nginx du site HormoneTrack Web (conteneur — remplace le default.conf).
#
# La partie « serveur statique » est triviale ; les choix qui comptent :
#
# 1. **No-cache systématique** — raison : l'app est une SPA versionnée par un
# jeu de fichiers (index.html + modules ES + asset PK) qui doivent rester
# cohérents entre eux. Un cache client périmé AU MOMENT d'une mise à jour
# d'image = code JS ancien + changelog nouveau = état incohérent. Le site
# complet ≈ 600 Ko : le coût du re-téléchargement est négligeable sur un
# LAN/VPN, et la fraîcheur après mise à jour est garantie.
#
# 2. **gzip** — l'asset pk_profiles.json (550 Ko de floats) se compresse
# ~70 % : prend le seul fichier lourd en charge.
#
# 3. **En-têtes de sécurité minimaux** (site statique sans API, sans cookie,
# sans iframe légitime).
#
# Ce fichier est copié dans /etc/nginx/conf.d/default.conf (server block) par
# le Dockerfile — le http{} parent de l'image inclut déjà mime.types (.js
# servi en application/javascript : requis pour les modules ES).
# ─────────────────────────────────────────────────────────────────────────────
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip : l'asset PK (550 Ko) se compresse ~4× — le seul fichier lourd
gzip on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types application/json application/javascript text/css text/markdown text/plain;
# ── Headers appliqués à toutes les réponses ──────────────────────────────
# No-cache « always » : une mise à jour d'image est prise en compte au
# rechargement suivant, sans qu'aucun client conserve du code périmé.
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# ── Routing ─────────────────────────────────────────────────────────────
# SPA à fichier unique : pas de réécriture (le routing est par HASH
# `#chart` etc., jamais par chemin) — un simple fallback 404 suffit.
location / {
try_files $uri $uri/ =404;
}
# Défensif : ne jamais servir les fichiers cachés (.gitignore,
# .dockerignore — ils sont normalement exclus par .dockerignore, la
# règle reste en filet si quelqu'un build depuis une copie non propre).
location ~ /\. {
deny all;
}
}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# check.sh — vérifications de l'app web (pré-commit / pré-push).
# check.sh — vérifications de l'app web (pré-commit / pré-push / pré-release).
#
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
# même de charger l'app dans un navigateur ;
@ -12,12 +12,26 @@
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
#
# Usage : bash scripts/check.sh [--with-serve]
# Mode RELEASE (--release) : ajoute les vérifications OBLIGATOIRES avant une
# release (cf docs/DEVELOPPEMENT.md §10) —
# 7. cohérence de la version : WEB_VERSION == section `## [X.Y.Z]` de
# docs/CHANGELOG.md == tag annoté `vX.Y.Z` existant (le bump de version
# jamais commité côté Android, bug #46, ne doit pas se reproduire ici) ;
# 8. arbre git propre (tout commité avant publication) ;
# 9. test conteneur RÉEL (scripts/container-test.sh — build/run durci/
# headers/gzip/MIME/E2E contre le conteneur) si un daemon Docker est
# joignable, ignoré sinon (à lancer sur l'hôte qui a Docker).
#
# Usage : bash scripts/check.sh [--with-serve] [--release]
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
cd "$(dirname "$0")/.." # racine du dépôt
fail=0
RELEASE=false
for arg in "$@"; do
[[ "$arg" == "--release" ]] && RELEASE=1
done
echo "── 1. Syntaxe des modules (node --check) ──"
while IFS= read -r f; do
@ -81,6 +95,47 @@ if [[ "${1:-}" == "--with-serve" ]]; then
wait $SRV 2>/dev/null || true
fi
if [[ "$RELEASE" == "1" ]]; then
echo "── 7. Cohérence de la version (release) ──"
ver=$(node --input-type=module -e "
import { WEB_VERSION } from './js/ui/settings.js';
process.stdout.write(WEB_VERSION);")
verGrep=$(echo "$ver" | sed 's/\./\\./g') # 1.4.10 → 1\.4\.10 pour grep -E
if grep -qE "^## \\[$verGrep\\]" docs/CHANGELOG.md; then
echo " WEB_VERSION = $ver ; section [$ver] présente dans docs/CHANGELOG.md"
else
echo "✗ WEB_VERSION ($ver) n'a pas de section correspondante dans docs/CHANGELOG.md"
fail=1
fi
# Le bump de version jamais commité (bug Android #46) ne doit pas se
# reproduire : la release est faite SUR un tag qui existe.
if git rev-parse -q --verify "refs/tags/v$ver" >/dev/null 2>&1; then
echo " tag v$ver présent"
else
echo "✗ tag v$ver ABSENT — créer et pousser le tag avant la release (leçon Android #46)"
fail=1
fi
echo "── 8. Arbre git propre avant publication ──"
if [[ -n "$(git status --porcelain)" ]]; then
echo "✗ working tree sale — tout committer avant la release"
git status --short | head -5
fail=1
else
echo " working tree propre"
fi
echo "── 9. Test conteneur RÉEL (si daemon Docker joignable) ──"
if docker info --format x >/dev/null 2>&1; then
if ! bash scripts/container-test.sh 8975; then
fail=1
fi
else
echo " (ignoré : aucun daemon Docker ici — lancer sur l'hôte cible :"
echo " bash scripts/container-test.sh ; cf docs/DEVELOPPEMENT.md §10)"
fi
fi
if [[ $fail -eq 0 ]]; then
echo "✓ CHECK OK"
else

126
scripts/container-test.sh Executable file
View File

@ -0,0 +1,126 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# container-test.sh — validation RÉELLE de l'image Docker HormoneTrack Web.
#
# C'est le test qui doit passer AVANT toute release web déployable en
# conteneur (cf docs/DEVELOPPEMENT.md §10) : il attrape ce qu'aucun test
# statique ne voit — MIME des modules ES (requis par les navigateurs),
# headers réellement émis, gzip appliqué, fichiers exclus non servis,
# healthcheck, et le comportement de l'app COMPLÈTE servie par le conteneur
# (via l'E2E playwright branclé sur le conteneur).
#
# Prérequis : docker (daemon) ; node + playwright pour l'E2E final (skippé
# sinon — cf check.sh pour l'installation).
#
# Usage : bash scripts/container-test.sh [port] (défaut 8975)
#
# ⚠️ Un SEUL invocation fait tout (leçon Android #43 : les invocations
# rapprochées se remplacent mutuellement) et l'échec est BRUYANT (exit 1
# à la première vérification fausse — jamais de « probablement bon »).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
cd "$(dirname "$0")/.." # racine du dépôt
PORT="${1:-8975}"
HOST_PORT="127.0.0.1:${PORT}"
IMAGE="hormonetrack-web:smoke"
CONTAINER="hrt-web-smoke"
ROOM=0 # compteur d'échecs
fail() { echo " ✗ $1"; ROOM=$((ROOM + 1)); }
ok() { echo " ✓ $1"; }
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "── 1. Build de l'image ──"
docker build -t "$IMAGE" . 2>&1 | tail -2
echo "── 2. Run (durcissement compose : read_only + no-new-privileges) ──"
# On teste l'image dans la configuration DURCIE de docker-compose.yml —
# si ces options ne passent pas ici, elles n'auraient pas mieux passé en
# prod. ⚠️ tmpfs /tmp requis : l'image nginx-unprivileged écrit ses
# temporaires (proxy_temp…) dans /tmp — en read-only pur, nginx émerge
# « mkdir /tmp/proxy_temp failed » (constaté au premier container-test).
docker run -d --rm --name "$CONTAINER" \
-p "${HOST_PORT}:8080" \
--read-only \
--tmpfs /var/cache/nginx --tmpfs /run --tmpfs /tmp \
--security-opt no-new-privileges \
"$IMAGE" >/dev/null
echo "── 3. Healthcheck (statut docker healthy) ──"
state=""
for _ in $(seq 1 30); do
state=$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo "")
if [[ "$state" == "healthy" ]]; then ok "healthcheck : healthy"; break; fi
sleep 1
done
[[ "$state" == "healthy" ]] || fail "healthcheck jamais healthy (état: '${state:-inconnu}')"
echo "── 4. Endpoints clés (HTTP 200) ──"
# Les MEMES chemins que le smoke HTTP de check.sh — le conteneur doit les
# servir TOUS (l'oubli de docs/ casserait le dialog « Nouveautés »).
for path in / /css/style.css /assets/pk_profiles.json /js/ui/app.js /docs/CHANGELOG.md; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}${path}")
if [[ "$code" == "200" ]]; then ok "200 ${path}"; else fail "${path} → HTTP ${code}"; fi
done
echo "── 5. Headers (no-cache + sécurité) ──"
hdr=$(curl -s -D - -o /dev/null "http://${HOST_PORT}/")
for want in "no-cache" "nosniff" "DENY" "no-referrer"; do
if echo "$hdr" | grep -qi "$want"; then ok "header présent : $want"; else fail "header manquant : $want"; fi
done
echo "── 5. gzip effectif sur l'asset PK (550 Ko → ~150 Ko) ──"
enc=$(curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w '%{header_json}' \
"http://${HOST_PORT}/assets/pk_profiles.json")
# ⚠️ curl %header_json produit des clés MINUSCULES (convention JSON) —
# chercher « content-encoding » pas « Content-Encoding ».
if echo "$enc" | grep -qi 'content-encoding.*gzip'; then ok "gzip appliqué"; else fail "pas de gzip sur l'asset — ${enc}"; fi
echo "── 6. MIME des modules ES (requête STRICTE des navigateurs) ──"
# Le piège réel du portage : un .js servi en text/html → « disallowed MIME
# type » et l'app ne charge PAS (cf §11 #6). On vérifie le CType réel.
mime=$(curl -s -o /dev/null -w '%{content_type}' "http://${HOST_PORT}/js/ui/app.js")
if echo "$mime" | grep -qi 'javascript'; then ok "module ES : ${mime}"; else fail "CType module ES = ${mime} (javascript requis)"; fi
echo "── 7. Fichiers cachés NON servis (défensif) ──"
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}/.gitignore")
if [[ "$code" == "403" || "$code" == "404" ]]; then ok "fichier caché non servi (${code})"; else fail "fichier caché servi → HTTP ${code}"; fi
echo "── 8. Le conteneur ne contient AUCUNE donnée de dev ──"
# Audit de contenu : ce qui est dans l'image (garde double de .dockerignore)
for excl in package-lock.json node_modules; do
if docker exec "$CONTAINER" find /usr/share/nginx/html -name "$excl" 2>/dev/null | grep -q .; then
fail "$excl trouvé dans l'image"
else
ok "image propre : pas de ${excl}"
fi
done
# docs/ DOIT être présent (dialog changelog)
if docker exec "$CONTAINER" test -f /usr/share/nginx/html/docs/CHANGELOG.md; then
ok "docs/CHANGELOG.md embarqué (dialog « Nouveautés »)"
else
fail "docs/CHANGELOG.md ABSENT de l'image — le dialog « Nouveautés » planerait"
fi
echo "── 9. E2E navigateur complet contre le conteneur (si playwright dispo) ──"
if [[ -d node_modules/playwright-core && -d "$HOME/Library/Caches/ms-playwright" ]]; then
if HRT_E2E_BASE="http://${HOST_PORT}" node scripts/e2e.mjs; then
ok "E2E complet contre le conteneur"
else
fail "E2E contre le conteneur"
fi
else
echo " (ignoré : playwright non installé — npm i && npx playwright install firefox)"
fi
if [[ $ROOM -eq 0 ]]; then
echo "✓ CONTAINER TEST OK"
else
echo "✗ CONTAINER TEST : ${ROOM} échec(s)"
exit 1
fi

View File

@ -40,8 +40,23 @@ function ok(condition, label, detail = '') {
}
}
/** Démarre le serveur statique de dev et attend qu'il réponde. */
/**
* Démarre le serveur statique de dev et attend qu'il réponde.
*
* ⚠️ MODE CONTENEUR : si la variable d'environnement HRT_E2E_BASE est définie
* (ex. HRT_E2E_BASE=http://127.0.0.1:8080), le serveur local n'est PAS
* démarré — l'E2E pilote l'app SÉRVIE PAR CE BASE. C'est ce qui permet de
* valider l'image Docker avec ce même test (cf docs/DEVELOPPEMENT.md §10
* « Valider le conteneur ») : build + run sur l'hôte Docker, puis
* `HRT_E2E_BASE=http://… node scripts/e2e.mjs`.
*
* @returns {process|null} le process serveur (à tuer à la fin), null en mode externe
*/
async function startServer() {
if (process.env.HRT_E2E_BASE) {
console.log(`serveur EXTERNE (HRT_E2E_BASE) : ${process.env.HRT_E2E_BASE}`);
return null; // rien à démarrer, rien à tuer
}
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
cwd: WEB_ROOT,
stdio: 'ignore',
@ -60,7 +75,10 @@ async function startServer() {
async function main() {
mkdirSync(SHOTS, { recursive: true });
const server = await startServer();
console.log(`serveur : ${BASE}`);
// BASE peut avoir été surchargée par HRT_E2E_BASE (mode conteneur) — les
// navigations ci-dessous ciblent quel que soit le serveur qui répond.
const base = process.env.HRT_E2E_BASE || BASE;
console.log(`cible E2E : ${base}`);
const browser = await firefox.launch({
headless: true,
@ -90,7 +108,7 @@ async function main() {
try {
// ── 1. Accueil avec données de démo ─────────────────────────────────────
console.log('── Accueil (?demo=1) ──');
await page.goto(`${BASE}/?demo=1`, { waitUntil: 'load' });
await page.goto(`${base}/?demo=1`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
await page.waitForSelector('.card', { timeout: 15000 });
const bodyText = await page.textContent('main.content');
@ -126,7 +144,7 @@ async function main() {
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
console.log('── Graphiques (#chart) ──');
await page.goto(`${BASE}/#chart`, { waitUntil: 'load' });
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
await page.waitForSelector('canvas.chart', { timeout: 15000 });
await page.waitForTimeout(600); // requestAnimationFrame du dessin
const canvasInfo = await page.evaluate(() => {
@ -151,7 +169,7 @@ async function main() {
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
console.log('── Navigation ──');
await page.goto(`${BASE}/#doses`, { waitUntil: 'load' });
await page.goto(`${base}/#doses`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.waitForTimeout(300);
let text = await page.textContent('main.content');
@ -159,18 +177,18 @@ async function main() {
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
await page.goto(`${BASE}/#labs`, { waitUntil: 'load' });
await page.goto(`${base}/#labs`, { waitUntil: 'load' });
await page.waitForTimeout(300);
text = await page.textContent('main.content');
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
await page.goto(`${BASE}/#treatments`, { waitUntil: 'load' });
await page.goto(`${base}/#treatments`, { waitUntil: 'load' });
await page.waitForTimeout(300);
text = await page.textContent('main.content');
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
await page.goto(`${BASE}/#settings`, { waitUntil: 'load' });
await page.goto(`${base}/#settings`, { waitUntil: 'load' });
await page.waitForTimeout(300);
text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue');
@ -185,7 +203,8 @@ async function main() {
consoleErrors.slice(0, 3).join(' | '));
} finally {
await browser.close();
server.kill();
// Mode conteneur : pas de serveur local démarré → rien à tuer (cf startServer)
server?.kill();
}
if (failures === 0) {

205
scripts/publish-release.py Normal file
View File

@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Publie la release Gitea d'une version WEB (attache l'archive zip du site,
VÉRIFIÉE PAR TÉLÉCHARGEMENT) — en UNE SEULE INVOCATION.
Portage du script Android `publish-release.py` (dépôt HormoneTrack),
adapté au web :
- UN SEUL artifact : `HormoneTrack-web-vX.Y.Z.zip` = déploiement
statique sans git (index.html + css/ + js/ + assets/ + docs/ +
LICENSE/README — le contenu runtime, cf .dockerignore) ;
- l'archive est construite PAR `git archive` AU NIVEAU DU TAG : le zip
correspond EXACTEMENT à ce qui est taggué (aucune divergence possible
working tree ↔ release) ;
- le corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md
(identique à l'Android — c'est le même parseur, piège #31 : tag « vX »
mais CHANGELOG sans v) ;
- mêmes gardes que l'Android (leçons #37/#43) : purge des assets avant
upload, vérification nom+taille par TÉLÉCHARGEMENT immédiat,
re-vérification finale, échec BRUYANT.
⚠️ Note de parité : les APK Android mentionnent le tag du dépôt Android ; ce
script ne doit être lancé QUE sur les tags du dépôt web (versions alignées).
Usage :
python3 scripts/publish-release.py [instance] vX.Y.Z
Auth : trousseau macOS (security find-internet-password -s <hôte> -w),
scope write:repository (suffit pour releases + assets).
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
# Instances connues — miroir du script Android (mêmes noms courts)
INSTANCES = {
"cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight",
"host": "gitea.cloudyfy.fr"},
"farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight",
"host": "gitea.farewell.dev"},
}
REPO_NAME = "HormoneTrack-web"
CHANGELOG = "docs/CHANGELOG.md"
def gitea_token(host: str) -> str:
out = subprocess.run(
["security", "find-internet-password", "-s", host, "-w"],
capture_output=True, text=True
).stdout.strip()
if not out:
sys.exit(f"Token Gitea introuvable dans le trousseau pour {host}\n"
f" security add-internet-password -s {host} -a Siphonight -w <TOKEN> -U")
return out
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
req = urllib.request.Request(url, method=method)
req.add_header("Authorization", f"token {token}")
if data is not None:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode()
with urllib.request.urlopen(req, timeout=120) as resp:
payload = resp.read().decode()
return json.loads(payload) if payload.strip() else {}
def _delivered_size(base: str, token: str, url: str) -> int:
"""Taille RÉELLEMENT délivrée — le listing API peut mentir (leçon Android
#37/#43 : on ne fait jamais confiance au listing, on télécharge)."""
r = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(r, timeout=300) as resp:
return len(resp.read())
def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
boundary = uuid.uuid4().hex
head = (f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
f"Content-Type: application/zip\r\n\r\n")
tail = f"\r\n--{boundary}--\r\n"
with open(path, "rb") as f:
req = urllib.request.Request(
f"{base}/releases/{release_id}/assets?name={name}", method="POST",
headers={"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}"})
req.data = head.encode() + f.read() + tail.encode()
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode())
def changelog_section(version: str) -> str:
"""Corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md.
⚠️ Même piège Android #31 : le tag porte le « v », la CHANGELOG non."""
num = version.removeprefix("v")
text = open(CHANGELOG, encoding="utf-8").read()
m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S)
if not m:
sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}")
return m.group(1).strip()
def verify_asset(base: str, token: str, release_id: int, asset_id: int,
name: str, expected: int) -> bool:
"""Vérifie par TÉLÉCHARGEMENT que l'asset a le bon nom et la bonne taille
(le listing API a déjà menti côté Android — on ne le croit pas)."""
release = request(f"{base}/releases/{release_id}", token=token)
current = next((a for a in release["assets"] if a["id"] == asset_id), None) \
if asset_id else next((a for a in release["assets"] if a["name"] == name), None)
if current is None or current["name"] != name:
return False
if abs(current["size"] - expected) > 1000:
return False
delivered = _delivered_size(base, token, current["browser_download_url"])
return delivered == expected
def build_zip(tag: str) -> str:
"""Construit l'archive de déploiement PAR GIT ARCHIVE au niveau du TAG —
le zip publié = EXACTEMENT ce que le tag contient (jamais le working
tree, qui peut être en avance). Exclusions : outils dev (tests/scripts)
+ fichiers du conteneur — le même contenu que l'image Docker (cf
.dockerignore) ; pour Docker, l'utilisatrice build depuis le dépôt.
"""
out = f"/tmp/HormoneTrack-web-{tag}.zip"
paths = ["index.html", "css", "js", "assets", "docs", "LICENSE", "README.md"]
subprocess.run(
["git", "archive", "--format=zip", f"-o{out}", tag] + paths, check=True)
print(f" zip construit depuis le tag : {out} ({os.path.getsize(out) // 1024} Ko)")
return out
def publish_zip(base: str, token: str, release_id: int, tag: str, zip_path: str) -> None:
"""Purge + upload du zip + vérification IMMÉDIATE par téléchargement,
puis re-vérification FINALE — le pattern Android qui marche
(les uploads rapprochés se remplacent mutuellement, lesson #43 :
tout est fait dans UNE invocation)."""
name = f"HormoneTrack-web-{tag}.zip"
# 1) Purge de tout asset existant (idempotent : relancer le script est sûr)
release = request(f"{base}/releases/{release_id}", token=token)
for a in release["assets"]:
urllib.request.urlopen(urllib.request.Request(
f"{base}/releases/{release_id}/assets/{a['id']}", method="DELETE",
headers={"Authorization": f"token {token}"}), timeout=60)
print(f" purge : {a['name']}")
# 2) Upload + vérification immédiate par téléchargement
name = f"HormoneTrack-web-{tag}.zip"
expected = os.path.getsize(zip_path)
uploaded = _upload_asset(base, token, release_id, name, zip_path)
if not verify_asset(base, token, release_id, uploaded["id"], name, expected):
sys.exit(f"✗ {name} : échec de vérification après upload")
print(f" ✓ {name} ({expected // 1024} Ko)")
# 3) Re-vérification FINALE (la race se voyait seulement ici côté Android)
if not verify_asset(base, token, release_id, 0, name, expected):
sys.exit(f"✗ {name} : re-vérification finale échouée")
print(f" ✓ zip vérifié par téléchargement")
def main():
args = sys.argv[1:]
instance = "cloudyfy"
if args and not args[0].startswith("v"):
instance = args.pop(0)
if instance not in INSTANCES:
sys.exit(f"Instance inconnue : {instance}")
if not args:
sys.exit(f"Usage: publish-release.py [cloudyfy|farewell] vX.Y.Z")
tag = args[0]
conf = INSTANCES[instance]
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
token = gitea_token(conf["host"])
body = changelog_section(tag)
zip_path = build_zip(tag)
# Release : crée ou récupère + PATCH corps (idempotent)
try:
release = request(f"{base}/releases/tags/{tag}", token=token)
request(f"{base}/releases/{release['id']}", "PATCH", token=token,
data={"tag_name": tag, "name": tag, "body": body,
"draft": False, "prerelease": False})
release_id = release["id"]
print(f"[{instance}] release {tag} existante (id {release_id}) — corps mis à jour")
except urllib.error.HTTPError as e:
if e.code != 404:
raise
release = request(f"{base}/releases", "POST", token=token, data={
"tag_name": tag, "name": tag, "body": body,
"draft": False, "prerelease": False})
release_id = release["id"]
print(f"[{instance}] release {tag} créée (id {release_id})")
publish_zip(base, token, release_id, tag, zip_path)
print(f"[{instance}] Release {tag} complète ✓")
if __name__ == "__main__":
main()