From a0245e72c2883cf22467174b56d2c0f1cd47f3af Mon Sep 17 00:00:00 2001 From: Siphonight Date: Tue, 8 Sep 2026 21:07:23 +0200 Subject: [PATCH] Conteneurisation Docker + tests de release (container-test, mode --release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 27 +++++ Dockerfile | 41 ++++++++ README.md | 21 ++++ docker-compose.yml | 34 ++++++ docs/DEVELOPPEMENT.md | 54 +++++++++- nginx.conf | 58 +++++++++++ scripts/check.sh | 59 ++++++++++- scripts/container-test.sh | 126 +++++++++++++++++++++++ scripts/e2e.mjs | 37 +++++-- scripts/publish-release.py | 205 +++++++++++++++++++++++++++++++++++++ 10 files changed, 646 insertions(+), 16 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100755 scripts/container-test.sh create mode 100644 scripts/publish-release.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8023a12 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbdc4e8 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 9e5c695..0d41423 100644 --- a/README.md +++ b/README.md @@ -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://: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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a1af17 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# ───────────────────────────────────────────────────────────────────────────── +# docker-compose — un commandement pour tourner HormoneTrack Web. +# +# docker compose up -d → http://: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 diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index ca72381..1b5094e 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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://: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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..074e4d5 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/scripts/check.sh b/scripts/check.sh index 44702e0..23004d2 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -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 diff --git a/scripts/container-test.sh b/scripts/container-test.sh new file mode 100755 index 0000000..e41e024 --- /dev/null +++ b/scripts/container-test.sh @@ -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 diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 1f7f69d..4bb15ea 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -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) { diff --git a/scripts/publish-release.py b/scripts/publish-release.py new file mode 100644 index 0000000..c02404e --- /dev/null +++ b/scripts/publish-release.py @@ -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 -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 -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()