diff --git a/.project/concepts/bdd-testing/knowledge_e2e-layer.md b/.project/concepts/bdd-testing/knowledge_e2e-layer.md index 0c42e78..28fbd4b 100644 --- a/.project/concepts/bdd-testing/knowledge_e2e-layer.md +++ b/.project/concepts/bdd-testing/knowledge_e2e-layer.md @@ -42,6 +42,26 @@ Navigation : `window.history.pushState` + dispatch `popstate` (routing path-base > **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]). +## Smoke `@smoke` — garde la classe « page blanche une fois connecté » + +`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une +CLASSE de régression : un crash de rendu qui ne survient QUE une fois l'app connectée +et montée sur des données réelles (symptôme : seul le bandeau de l'iframe broker +s'affiche, `#root` reste vide). Le smoke réutilise le boot du hook `Before` @e2e, +navigue vers l'accueil connecté et asserte DEUX choses : +1. **HomeScreen a réellement monté** — présence de marqueurs forts (`.app-navbar` + + bouton `[aria-label="Relayer un événement"]`), absents d'un spinner / du bandeau + broker. Un `throw` dans un composant/provider monté après connexion démonte l'arbre + (aucun `ErrorBoundary`) → ces marqueurs disparaissent → rouge. +2. **Zéro erreur runtime** — `this.pageErrors` (voir ci-dessous) doit être vide. + +Le hook `Before` @e2e **collecte** désormais dans le World les `pageerror` + +`console.error` de la page app (champ `pageErrors`, réinitialisé par scénario) — c'est +ce qui rend l'assertion « pas d'erreur » possible. Le run par défaut de `bun run +validate` exécute `@smoke and not @wip` (pas tout `@e2e`, pour rester rapide). +**Preuve de détection** : un `throw` en tête de `HomeScreen` fait virer le smoke au +rouge ; sans lui, vert. + ## Fichiers clés -`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored). +`src/shared/support/hooks.ts` (lifecycle Playwright + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored). diff --git a/scripts/validate.ts b/scripts/validate.ts index 449a3d6..5cb16f6 100644 --- a/scripts/validate.ts +++ b/scripts/validate.ts @@ -7,7 +7,8 @@ * (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts) * (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs) * (d) Festipod @multibrowser (cucumber --tags @multibrowser) - * (e) Festipod @wip [informational only, non-blocking] + * (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend) + * (f) Festipod @wip [informational only, non-blocking] * * With --full flag: * (c) becomes full @data suite (cucumber --tags @data) @@ -53,6 +54,7 @@ const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset) const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full) const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min +const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario) const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min /** @@ -447,7 +449,24 @@ async function main(): Promise { ), ); - // ── (e) Festipod @wip [informational] ──────────────────────────────────── + // ── (e) Festipod @smoke — boot connecté rend / page blanche ─────────────── + // Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie + // que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde + // la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance + // QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide. + // Nettoie les singletons Chromium juste avant, comme les autres étapes broker. + cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)"); + steps.push( + runStep( + "festipod:@smoke", + "node", + cucumberArgsByTags("@smoke and not @wip"), + FESTIPOD_DIR, + TIMEOUT_SMOKE_MS, + ), + ); + + // ── (f) Festipod @wip [informational] ──────────────────────────────────── console.log("\n── @wip informational pass (non-blocking) ──────────────────"); const wipResult = runStep( "festipod:@wip", diff --git a/src/modules/home/features/accueil-connecte-rend.feature b/src/modules/home/features/accueil-connecte-rend.feature new file mode 100644 index 0000000..c6a728e --- /dev/null +++ b/src/modules/home/features/accueil-connecte-rend.feature @@ -0,0 +1,31 @@ +# language: fr +# +# SMOKE « crash de rendu connecté / page blanche ». +# +# Régression gardée : après connexion au broker, seul le bandeau de l'iframe +# s'affichait et le vrai App ne rendait rien (page blanche). Aucun test +# n'attrapait cette CLASSE de bug — un crash de rendu qui ne survient QUE une +# fois l'app connectée et montée sur des données réelles. +# +# Ce smoke boote le VRAI App (serveur src/index.ts, pas le harness __testData), +# se connecte au broker (fourni par le hook Before @e2e), navigue vers l'accueil +# connecté (HomeScreen, qui consomme useFestipodData) et vérifie DEUX choses : +# 1. l'accueil rend un élément d'app significatif (barre de navigation + bouton +# « Relayer »), preuve que HomeScreen a monté — pas juste un spinner ou le +# bandeau du broker ; +# 2. AUCUNE erreur runtime (pageerror / console.error) n'a été émise pendant +# le boot connecté — un throw dans un composant/provider monté après +# connexion vire le smoke au rouge. +# +# Tout futur crash de rendu connecté = ce smoke passe au rouge. + +@e2e @smoke +Fonctionnalité: L'accueil connecté rend du contenu réel + En tant qu'utilisateur qui vient de se connecter + Je veux que l'écran d'accueil rende réellement l'application + Afin de ne jamais retomber sur une page blanche une fois connecté + + Scénario: L'accueil rend du contenu réel sans erreur après connexion + Quand l'application connectée affiche l'accueil + Alors l'accueil rend un contenu d'application réel + Et aucune erreur runtime n'a été émise pendant le boot connecté diff --git a/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts b/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts new file mode 100644 index 0000000..90fa44b --- /dev/null +++ b/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts @@ -0,0 +1,75 @@ +import { When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; + +// --- Smoke « crash de rendu connecté / page blanche » --- +// +// Le hook Before @e2e a déjà : booté le VRAI App via le broker (this.appFrame), +// attendu que #root ait du contenu, et laissé 3s aux providers pour se +// stabiliser. On ne re-boote donc PAS l'app ici — on réutilise this.appFrame. +// +// Ces steps prouvent que l'App a réellement MONTÉ l'accueil connecté (HomeScreen, +// qui consomme useFestipodData), pas juste un spinner ou le bandeau du broker. + +When("l'application connectée affiche l'accueil", async function (this: FestipodWorld) { + // Un utilisateur connecté qui atterrit sur '/' est redirigé vers '/home' par + // WelcomeScreen. On navigue explicitement pour rendre le smoke déterministe + // quel que soit l'état de la redirection au moment du boot. + await this.appFrame!.evaluate(() => { + window.history.pushState(null, '', '/home'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + // Laisse le routeur et HomeScreen (re)monter. + await this.appFrame!.waitForTimeout(1000); +}); + +Then("l'accueil rend un contenu d'application réel", async function (this: FestipodWorld) { + // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un + // simple spinner / du bandeau broker) : + // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par + // HomeScreen, pas par l'écran d'onboarding ; + // - le bouton « Relayer » (aria-label="Relayer un événement") propre à + // l'en-tête de l'accueil. + // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit + // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs + // disparaissent → l'attente échoue. + const rendered = await this.appFrame!.waitForFunction( + () => { + const root = document.getElementById('root'); + if (!root) return false; + const hasNavbar = document.querySelector('.app-navbar') !== null; + const hasRelayer = + document.querySelector('[aria-label="Relayer un événement"]') !== null; + return hasNavbar && hasRelayer; + }, + { timeout: 15000 }, + ).then(() => true).catch(() => false); + + if (!rendered) { + const debug = await this.appFrame!.evaluate(() => ({ + pathname: window.location.pathname, + hasNavbar: document.querySelector('.app-navbar') !== null, + hasRelayer: document.querySelector('[aria-label="Relayer un événement"]') !== null, + rootLen: document.getElementById('root')?.innerHTML.length ?? 0, + rootText: document.getElementById('root')?.textContent?.substring(0, 400), + })); + expect.fail( + `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` + + `path="${debug.pathname}", .app-navbar=${debug.hasNavbar}, ` + + `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` + + `texte: "${debug.rootText}"`, + ); + } +}); + +Then('aucune erreur runtime n\'a été émise pendant le boot connecté', function (this: FestipodWorld) { + // this.pageErrors est peuplé par le hook Before (pageerror + console.error de + // la page app), réinitialisé à chaque scénario. Un crash de rendu connecté + // (throw non attrapé dans un composant/provider) émet un `pageerror` et + // atterrit ici → assertion rouge avec la liste exacte. + expect( + this.pageErrors, + `Des erreurs runtime ont été émises pendant le boot connecté :\n` + + this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'), + ).to.be.empty; +}); diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index 4c848a1..690330f 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -547,6 +547,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { this.isAuthenticated = false; this.screenSourceContent = ''; this.currentScreen = null; + this.pageErrors = []; // Multi-browser scenarios drive their own isolated browsers via steps // (this.openBrowser). They must NOT get the legacy single shared page. @@ -593,10 +594,19 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque origin */ } }, freshUser); - // Capture console for debugging - this.page.on('pageerror', (err) => console.error('[Browser error]', err.message)); + // Capture console for debugging AND collect into the World so smoke + // scenarios can assert no runtime error was emitted during the connected + // boot (guards the "page blanche once connected" render-crash class). + const world = this; + this.page.on('pageerror', (err) => { + console.error('[Browser error]', err.message); + world.pageErrors.push(`pageerror: ${err.message}`); + }); this.page.on('console', (msg) => { - if (msg.type() === 'error') console.error('[Browser console]', msg.text()); + if (msg.type() === 'error') { + console.error('[Browser console]', msg.text()); + world.pageErrors.push(`console.error: ${msg.text()}`); + } }); } diff --git a/src/shared/support/world.ts b/src/shared/support/world.ts index b98c49d..3ec7f1e 100644 --- a/src/shared/support/world.ts +++ b/src/shared/support/world.ts @@ -24,6 +24,12 @@ export interface FestipodWorld extends World { page: Page | null; appFrame: Frame | null; + // Runtime errors emitted by the app page during the scenario (uncaught + // `pageerror` + console.error). Collected by the Before hook so smoke + // scenarios can assert the connected app booted WITHOUT crashing the render + // (guards the "page blanche once connected" class of bug). Reset per scenario. + pageErrors: string[]; + // Multi-browser (named, isolated contexts) — for cross-browser wallet tests. // The wallet model (own vs shared) is an axis orthogonal to browser count. browsers: Map; @@ -239,6 +245,9 @@ class CustomWorld extends World implements FestipodWorld { page: Page | null = null; appFrame: Frame | null = null; + // Runtime errors emitted by the app page during the scenario (see interface). + pageErrors: string[] = []; + // Multi-browser (named, isolated contexts) browsers: Map = new Map();