Siphonight d822af2dd2 v1.7.0 : unités des axes du graphique (portage Android) + note auto-backup
- chart-canvas.js : pg/mL (E2, axe gauche) / ng/mL (T, axe droit) au
  sommet des colonnes de labels — miroir exact du fix Android (padTop
  élargi 12 → 26 px). Universelles, PAS des clés i18n.
- WEB_VERSION 1.7.0 (versions sync Android) ; E2E assertions version.
- CHANGELOG [1.7.0] : unités portées ; auto-backup Android NON porté
  (structurellement impossible côté navigateur — cf §12 Limites :
  pas d'OPEN_DOCUMENT_TREE persisté + WorkManager ; l'export/import
  manuel couvre la même donnée).
- Doc : §12 limites, README statut. check.sh vert (140 tests + E2E +
  garde de parité §3.bis).
2026-09-16 19:53:41 +02:00

312 lines
16 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.7.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') });
// ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ───────────────────
// Bug remonté : les options se réinitialisaient « toutes seules » car le
// tick minute / une mutation du store / un resize re-créaient l'écran
// ENTIER (renderRoute) et le state local de renderChart retombait aux
// défauts. Fix : état au niveau module + preserveState du même écran.
// Ici on déclenche le re-render via RESIZE (même code path que le tick :
// store.subscribe et setInterval appellent tous renderRoute) — le tick
// réel de 60 s est trop lent pour un E2E.
console.log('── Persistance des options du graphique (#chart, v1.6.1) ──');
// « Tracé labs » est ON à ce stade (scénario 2.bis) — vérifions avant :
const selectedBefore = await page.evaluate(() =>
[...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')));
ok(selectedBefore, 'pré-état : chip « Tracé labs » activé');
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
await page.waitForTimeout(500);
const stateAfterResize = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
legend: document.querySelector('main.content').textContent.includes('E2 ancrée sur tes labs'),
}));
ok(stateAfterResize.labTrackSelected,
'resize → le chip « Tracé labs » reste sélectionné (état préservé, bug v1.6.1 corrigé)');
ok(stateAfterResize.legend,
'resize → la légende « Tracé labs » reste affichée (le re-rendu n\'a pas tout remis à zéro)');
// Reset à l'ENTRÉE (navigation) : quitter puis revenir → défauts,
// miroir du reset d'un `remember` Android quand on quitte l'écran
await page.goto(`${base}/#home`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.waitForTimeout(400);
const stateAfterNav = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
}));
ok(stateAfterNav.labTrackSelected === false,
'navigation (quitter → revenir) → options retombées aux défauts (miroir Android)');
// ── 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.7.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);
});