Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) : - js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab], < 2 labs → vide, indépendante de la calibration (sinon double correction). JAMAIS dans levelAt/alertes. - UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles + scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ; couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE de la boucle légende (sinon légende TFS dupliquée). - 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total). - WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README). Détail d'exécution épingle: les deux problèmes du portage ont été attrapés par node --check/E2E avant release (imports ESM faux — labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
222 lines
10 KiB
JavaScript
222 lines
10 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.5.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') });
|
||
|
||
// ── 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.5.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);
|
||
});
|