Ng eventually #1
@@ -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).
|
||||
|
||||
+21
-2
@@ -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<void> {
|
||||
),
|
||||
);
|
||||
|
||||
// ── (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",
|
||||
|
||||
@@ -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é
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, NamedBrowser>;
|
||||
@@ -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<string, NamedBrowser> = new Map();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user