Siphonight a0245e72c2 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.
2026-09-08 21:07:23 +02:00

222 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* e2e.mjs — test end-to-end de l'app web dans un VRAI navigateur.
*
* Ce que ce test vérifie (impossible en Node pur) :
* 1. l'app démarre dans un navigateur : modules ES chargés, asset PK fetché,
* aucune erreur console fatale ;
* 2. les écrans se rendent avec de vraies données (?demo=1) ;
* 3. le Canvas du graphique PEINT réellement des courbes (pixels non vides) ;
* 4. la navigation par hash fonctionne (5 onglets + settings) ;
*
* Prérequis (une fois, cf docs/DEVELOPPEMENT.md §Tests) :
* npm i → installe playwright/playwright-core (devDependencies)
* npx playwright install firefox → le build Firefox piloté par Playwright
* Le navigateur système n'est PAS utilisé (Firefox ≥ ~150 ne supporte plus
* le protocole Juggler de Playwright) ; le build téléchargé vit dans le
* cache Playwright (~90 Mo, hors dépôt).
*
* Usage : node scripts/e2e.mjs [port]
*/
import { firefox } from 'playwright-core';
import { spawn } from 'node:child_process';
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.argv[2] || 8980);
const BASE = `http://127.0.0.1:${PORT}`;
const SHOTS = '/tmp/hrt-web-shots';
let failures = 0;
function ok(condition, label, detail = '') {
if (condition) {
console.log(` ✓ ${label}`);
} else {
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
failures++;
}
}
/**
* 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',
});
for (let i = 0; i < 50; i++) {
try {
await fetch(`${BASE}/index.html`);
return proc;
} catch (e) {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error('serveur de dev non démarré');
}
async function main() {
mkdirSync(SHOTS, { recursive: true });
const server = await startServer();
// 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,
firefoxUserPrefs: {
'dom.notifications.enabled': false, // pas de popup de permission en test
},
});
const page = await browser.newPage({ viewport: { width: 420, height: 900 } });
// Le Firefox headless démarre en-US : on seed la langue FR AVANT toute
// exécution de script (comme une utilisatrice qui a choisi Français) —
// toutes les assertions de texte de ce test sont en FR.
// ⚠️ CONDITIONNEL : écraser à chaque navigation effacerait aussi
// changelogSeenVersion → le dialog « Nouveautés » réapparaîtrait à chaque
// page.goto (une goto = reboot complet de la SPA).
await page.addInitScript(() => {
if (!window.localStorage.getItem('hormonetrack.settings')) {
window.localStorage.setItem('hormonetrack.settings',
JSON.stringify({ language: 'fr' }));
}
});
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', (err) => consoleErrors.push(String(err)));
try {
// ── 1. Accueil avec données de démo ─────────────────────────────────────
console.log('── Accueil (?demo=1) ──');
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');
ok(bodyText.includes('Niveau actuel'), 'carte « niveau actuel » rendue');
ok(/≈ \d+/.test(bodyText), `valeur E2 estimée affichée (${(bodyText.match(/≈ \d+(?:\.\d+)?/) || ['?'])[0]})`);
ok(bodyText.includes('ng/mL'), 'valeur T estimée affichée');
ok(bodyText.includes('Prochaine dose'), 'carte « prochaine dose » (rappel démo 18:00)');
// Le niveau doit être physiologique (EEn 5 mg à l'équilibre TFS ≈ 160-270)
const levelMatch = bodyText.match(/≈ (\d+(?:\.\d+)?)/);
if (levelMatch) {
const e2 = Number(levelMatch[1]);
ok(e2 > 50 && e2 < 600, `E2 estimé plausible (${e2} pg/mL)`, `obtenu ${e2}`);
}
// Premier lancement → le dialog « Nouveautés » est affiché (changelog v0.1.0
// en markdown rendu) : vérifier son contenu PUIS le fermer pour capturer
// l'accueil réel (les captures doivent montrer l'app, pas l'overlay).
const dialog = await page.$('.dialog');
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) {
const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button');
await page.waitForSelector('.dialog', { state: 'detached' });
}
// Après fermeture : la bannière démo est visible au-dessus du contenu
const afterClose = await page.textContent('main.content');
ok(afterClose.includes('DONNÉES DE DÉMO'), 'bannière démo visible');
await page.screenshot({ path: join(SHOTS, 'e2e-home.png'), fullPage: false });
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
console.log('── Graphiques (#chart) ──');
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(() => {
const canvas = document.querySelector('canvas.chart');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
const data = ctx.getImageData(0, 0, width, height).data;
let colored = 0; // pixels non transparents (le fond du canvas est transparent)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) colored++;
}
return { width, height, colored };
});
ok(canvasInfo.colored > 5000,
`canvas peint (${canvasInfo.colored} px colorés sur ${canvasInfo.width}×${canvasInfo.height})`);
const legendText = await page.textContent('main.content');
ok(legendText.includes('Transfem Science'), 'toggle/légende TFS présente');
// Légende E2 + T + labs + hint pan
ok(legendText.includes('E2 —'), 'légende E2');
ok(legendText.includes('Glisser horizontalement'), 'hint panoramique');
await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') });
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
console.log('── Navigation ──');
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');
ok(text.includes('Sous THS depuis'), 'Doses : en-tête « temps sous THS »');
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.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.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.waitForTimeout(300);
text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
// ── 4. Erreurs console ──────────────────────────────────────────────────
console.log('── Santé ──');
ok(consoleErrors.length === 0,
'aucune erreur console/page',
consoleErrors.slice(0, 3).join(' | '));
} finally {
await browser.close();
// Mode conteneur : pas de serveur local démarré → rien à tuer (cf startServer)
server?.kill();
}
if (failures === 0) {
console.log('✓ E2E OK');
} else {
console.error(`✗ E2E : ${failures} échec(s)`);
process.exit(1);
}
}
main().catch((e) => {
console.error('✗ E2E a planté :', e.message);
process.exit(1);
});