Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

203 lines
9.1 KiB
JavaScript
Raw 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. */
async function startServer() {
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();
console.log(`serveur : ${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.4.10'), '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.4.10 (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();
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);
});