- lab-timing.js : computeRegimeStartMs — le régime courant = séquence terminale de doses à (ester effectif, dose, écart inter-doses) constants (écart comparé EXACTEMENT) : tout changement récent de dose/ester/ posologie réinitialise la stabilisation. L'ancien proxy (1ʳᵉ dose du traitement) disait « stabilisée depuis février » malgré des changements récents (remontée v1.8.0). L'encart porte l'ESTER EFFECTIF de la dernière dose (override compris). - home.js : encart « Prochaine prise de sang (suggestion) » sur l'ACCUEIL (demande) — creux daté + créneau associé + mention de stabilisation seulement si le régime n'était pas déjà stable. - 3 nouveaux tests miroirs (155 verts) : dose/intervalle/ester → reset épinglé. WEB_VERSION 1.8.1. CHANGELOG [1.8.1], README.
328 lines
17 KiB
JavaScript
328 lines
17 KiB
JavaScript
#!/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.8.1'), '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)');
|
||
// v1.7.1 : la dernière paire de démo porte des notes DISTINCTES (E2 ≠ T)
|
||
// → les DEUX doivent s'afficher, préfixées de leur marqueur (fix du bug
|
||
// « une seule note sur deux »)
|
||
ok(text.includes('E2 : Prise de sang après des maux de tête.'),
|
||
'Labs : note E2 affichée (préfixée)');
|
||
ok(text.includes('T : DHT : 12 ng/dL'),
|
||
'Labs : note T affichée (préfixée) — les deux notes coexistent');
|
||
// v1.8.0 : encart « prochaine prise de sang recommandée » — la démo a un
|
||
// EEn actif à Posologie 7 j → l'encart DOIT être là (creux + créneau + note)
|
||
ok(text.includes('Prochaine prise de sang (suggestion)'),
|
||
'Labs : encart recommandation de prochaine prise de sang rendu');
|
||
ok(/Au creux estimé : \d{2}\/\d{2}\/\d{4}/.test(text),
|
||
'Labs : creux estimé daté dans l\'encart');
|
||
ok(text.includes('Juste avant ton injection de EEN'),
|
||
'Labs : créneau d\'injection associé mentionné');
|
||
ok(text.includes('stabilisé'), 'Labs : mention de stabilisation présente');
|
||
|
||
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.8.1 (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);
|
||
});
|