Siphonight 551d8fa29e v1.6.0 (web, UI + E2E) : chip Prolonger, split LAB/LABX, avertissement, WEB_VERSION 1.6.0
- chart.js : chip « Prolonger » (off par défaut, désactivé tant que
  Tracé labs est off), split série "LAB" (ancrée) / "LABX" (prolongée,
  rose atténué rgba(194,24,91,0.55)), légendes dédiées (clés LAB ET LABX
  skippées de la boucle — miroir leçon #63), AVERTISSEMENT « simple
  simulation, sans garantie de correspondre au réel, labs potentiellement
  erronés » affiché sous la légende tant que la prolongation est visible
  (condition factorisée : jamais de courbe prolongée sans avertissement).
- i18n : show_lab_track_extend, legend_lab_track_extend,
  lab_track_extend_warning (FR/EN synchronisées).
- E2E : scénario v1.6.0 (chip désactivé → activation → légende +
  avertissement → disparition à la désactivation) ; assertions version
  1.6.0.
2026-09-12 23:51:20 +02:00

273 lines
13 KiB
JavaScript
Raw Permalink 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.6.0'), '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 <span class="chip"> (onclick inline), PAS des
// <button> — seuls − / + sont de vrais boutons ; « disabled » est
// simulé par style opacity:0.4 (pas d'attribut disabled).
console.log('── Tracé labs prolongé (#chart, v1.6.0) ──');
for (let i = 0; i < 5; i++) {
await page.evaluate(() => {
[...document.querySelectorAll('button.chip')].find((x) => x.textContent.trim() === '−')?.click();
});
}
await page.waitForTimeout(400);
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
const extendDisabledBefore = await page.evaluate((lbl) => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl);
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
}, 'Prolonger');
ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off');
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Tracé labs');
await page.waitForTimeout(400);
const labTrackText = await page.textContent('main.content');
ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée');
ok(!labTrackText.includes('Simple simulation, sans garantie'),
'pas d\'avertissement tant que la prolongation est off');
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400);
const extendText = await page.textContent('main.content');
ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée');
ok(extendText.includes('Simple simulation, sans garantie'),
'AVERTISSEMENT « simulation sans garantie » visible');
ok(extendText.includes('qui peuvent eux-mêmes être erronés'),
'avertissement : les résultats de lab peuvent être erronés');
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400);
const offText = await page.textContent('main.content');
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
'désactivation → légende prolongée + avertissement disparaissent');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.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.6.0 (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);
});