#!/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.9.4'), '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') }); // ── 2.bis Tracé labs PROLONGÉ (v1.6.0) : chips, légende, avertissement ── // La démo place le dernier lab à now − 7 j : on dézoome (24 h → 32 j) // pour que la partie ANCRÉE soit dans la fenêtre, puis on active // « Tracé labs » puis « Prolonger ». ⚠️ Chaque interaction re-rend le // DOM (redraw) → re-chercher le chip avant CHAQUE clic. ⚠️ Les chips // filterChip sont des (onclick inline), PAS des //