Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
6 changed files with 170 additions and 6 deletions
Showing only changes of commit 7302936502 - Show all commits
@@ -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
View File
@@ -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;
});
+13 -3
View File
@@ -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()}`);
}
});
}
+9
View File
@@ -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();