E2E testing infrastructure, NextGraph connection fixes, and documentation

- Add @e2e test layer: real app in broker iframe via Playwright
- Fix broker redirect: conditional auto-init only when inside iframe
- Fix seed data flash: empty data during 'connecting' phase
- Fix Gallery button in iframe: explicit navigate instead of history.back
- Add auth e2e feature scenarios and step definitions
- Update docs: bdd-testing, data-layer-testing, data-layer, AGENTS.md
- Add decision record for conditional NG init approach

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-03-13 17:51:44 +01:00
parent 6f9b3ece34
commit 708cbeead8
23 changed files with 12033 additions and 361 deletions
@@ -0,0 +1,78 @@
# language: fr
@AUTH @priority-1
Fonctionnalité: Connexion NextGraph et chargement des données
En tant qu'utilisateur
Je peux me connecter à mon portefeuille NextGraph
Et charger les données de test dans mon portefeuille
Afin d'utiliser l'application avec mes propres données
# --- UI layer: écran de connexion ---
@ui
Scénario: L'écran de connexion affiche le bouton NextGraph
Étant donné je suis sur la page "connexion"
Alors l'écran contient un bouton "Se connecter avec NextGraph"
@ui
Scénario: L'écran de connexion redirige automatiquement quand connecté
Étant donné je suis sur la page "connexion"
Alors l'écran gère la redirection automatique après connexion
@ui
Scénario: L'état initial est "en cours" quand une connexion est en attente
Étant donné je suis sur la page "connexion"
Alors l'écran gère l'état de connexion en cours
@ui
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
Étant donné je suis sur la page "connexion"
Alors l'écran n'importe pas de données de démonstration
# --- Data layer: comportement du portefeuille ---
@data
Scénario: Un portefeuille connecté est vide par défaut
Alors le portefeuille est connecté
Et le portefeuille ne contient aucun événement de démonstration
@data
Scénario: Charger les données de test dans le portefeuille
Étant donné que le portefeuille est vide
Quand je charge les données de test
Alors le portefeuille contient des événements
Et le portefeuille contient des utilisateurs
@data
Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données
Étant donné que le portefeuille contient déjà des événements
Quand je charge les données de test
Alors le nombre d'événements n'a pas changé
@data
Scénario: Les données du portefeuille sont distinctes des données par défaut
Étant donné que le portefeuille est vide
Quand je charge les données de test
Alors les événements ont des identifiants NextGraph
Et les utilisateurs ont des identifiants NextGraph
# --- E2E layer: comportement réel dans le navigateur ---
@e2e
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
Quand l'utilisateur navigue vers l'écran "login"
Alors l'application affiche l'écran "home"
@e2e
Scénario: La navigation interne met à jour l'URL
Quand l'utilisateur navigue vers l'écran "events"
Alors l'URL contient "demo/events"
@e2e
Scénario: L'application ne redirige pas vers le broker quand elle est dans l'iframe
Alors l'application est toujours dans l'iframe
@e2e
Scénario: Le bouton Galerie ramène à la galerie depuis le mode démo
Quand l'utilisateur navigue vers l'écran "home" sans historique
Et l'utilisateur clique sur le bouton "Galerie"
Alors l'application affiche la galerie
+10 -1
View File
@@ -1,10 +1,19 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
import type { ScreenProps } from '../../../screens';
export function LoginScreen({ navigate }: ScreenProps) {
const { status, connect } = useNextGraph();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
// Auto-navigate to home when connection completes
useEffect(() => {
if (status === 'connected') {
navigateRef.current('home');
}
}, [status]);
const handleNgLogin = () => {
if (status === 'connected') {
@@ -0,0 +1,151 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Seed data matching what bootstrapWallet uses
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
// --- Setup ---
Given('le portefeuille est vide', async function (this: FestipodWorld) {
// Verify starting state: the harness graph should have its own seeded data.
// We clear events/users/participations to simulate a truly empty wallet.
await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
// Delete all events
for (const e of [...td.events]) td.events.delete(e);
// Delete all users
for (const u of [...td.users]) td.users.delete(u);
// Delete all participations
for (const p of [...td.participations]) td.participations.delete(p);
});
// Verify empty
const counts = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
});
expect(counts.events, 'Events should be empty').to.equal(0);
expect(counts.users, 'Users should be empty').to.equal(0);
});
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
const count = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.events.size;
});
// If empty, seed some data first so the precondition holds
if (count === 0) {
await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
td.loadTestData();
});
// Wait for data to propagate
await this.appFrame!.waitForFunction(
() => (window as any).__testData.events.size > 0,
{ timeout: 10000 },
);
}
});
// --- Actions ---
When('je charge les données de test', async function (this: FestipodWorld) {
// Store count before loading for the idempotency test
const countBefore = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.events.size;
});
(this as any)._eventCountBefore = countBefore;
await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
td.loadTestData();
});
// Wait for data to propagate (if wallet was empty, data should appear)
await this.appFrame!.waitForFunction(
() => {
const td = (window as any).__testData;
// Either data was already there, or it should appear after loading
return td.events.size > 0 || td._loadResult?.seeded === false;
},
{ timeout: 10000 },
).catch(() => {
// Timeout is OK if wallet was already populated (idempotent case)
});
});
// --- Assertions ---
Then('le portefeuille est connecté', async function (this: FestipodWorld) {
const hasSession = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.session !== undefined && td.session !== null;
});
expect(hasSession, 'Wallet should have an active NG session').to.be.true;
});
Then('le portefeuille ne contient aucun événement de démonstration', async function (this: FestipodWorld) {
// Wallet should be empty — no auto-seeding.
// Also verify through the app's data context (same view as screens).
const result = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return {
walletEvents: td.events.size,
appEvents: td.appData?.events?.length ?? -1,
ngStatus: td.ngStatus,
};
});
expect(result.walletEvents, 'Wallet should have no events').to.equal(0);
// App-level view should also show no events (providers working correctly)
expect(result.appEvents, 'App data context should show no events').to.equal(0);
expect(result.ngStatus, 'NG status should be connected').to.equal('connected');
});
Then('le portefeuille contient des événements', async function (this: FestipodWorld) {
const count = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.events.size;
});
expect(count, 'Wallet should contain events').to.be.greaterThan(0);
});
Then('le portefeuille contient des utilisateurs', async function (this: FestipodWorld) {
const count = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.users.size;
});
expect(count, 'Wallet should contain users').to.be.greaterThan(0);
});
Then('le nombre d\'événements n\'a pas changé', async function (this: FestipodWorld) {
const countBefore = (this as any)._eventCountBefore as number;
const countAfter = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return td.events.size;
});
expect(countAfter, 'Event count should not change after reload').to.equal(countBefore);
});
Then('les événements ont des identifiants NextGraph', async function (this: FestipodWorld) {
const ids = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return [...td.events].map((e: any) => e['@id']);
});
expect(ids.length, 'Should have events').to.be.greaterThan(0);
for (const id of ids) {
expect(id, `Event ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
}
});
Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {
const ids = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return [...td.users].map((u: any) => u['@id']);
});
expect(ids.length, 'Should have users').to.be.greaterThan(0);
for (const id of ids) {
expect(id, `User ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
}
});
@@ -0,0 +1,121 @@
import { When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// --- E2E step definitions ---
// These interact with the REAL app running in the browser (via broker iframe),
// not the test harness bridge. They test actual UI behavior.
// Screen content markers — text that uniquely identifies each screen
const SCREEN_MARKERS: Record<string, string> = {
'home': 'Mes événements à venir',
'events': 'Découvrir',
'login': 'connecter',
'profile': 'Mon profil',
'create-event': "Nom de l'événement",
'settings': 'Paramètres',
};
When('l\'utilisateur navigue vers l\'écran {string}', async function (this: FestipodWorld, screenId: string) {
await this.appFrame!.evaluate((id: string) => {
window.location.hash = `#/demo/${id}`;
}, screenId);
// Wait for React to process the navigation
await this.appFrame!.waitForTimeout(1500);
});
When('l\'utilisateur navigue vers l\'écran {string} sans historique', async function (this: FestipodWorld, screenId: string) {
// Use replaceState to navigate without creating a back-history entry
// (simulates the app being loaded directly at a DemoMode URL in the broker iframe)
await this.appFrame!.evaluate((id: string) => {
window.history.replaceState(null, '', `#/demo/${id}`);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, screenId);
await this.appFrame!.waitForTimeout(1500);
});
Then('l\'application est toujours dans l\'iframe', async function (this: FestipodWorld) {
// Verify the app didn't redirect away (initNgWeb would redirect if not in iframe)
const url = await this.appFrame!.evaluate(() => window.location.href);
expect(url, 'App should still be on localhost, not redirected to broker').to.include('127.0.0.1');
// Verify the app rendered (not a blank page or error)
const hasContent = await this.appFrame!.evaluate(() => {
const root = document.getElementById('root');
return root && root.innerHTML.length > 100;
});
expect(hasContent, 'App should have rendered content').to.be.true;
});
Then('l\'application affiche l\'écran {string}', async function (this: FestipodWorld, expectedScreenId: string) {
const marker = SCREEN_MARKERS[expectedScreenId];
// Wait for the expected screen to appear (handles async redirects like login→home)
const appeared = await this.appFrame!.waitForFunction(
([id, markerText]: [string, string | undefined]) => {
const hash = window.location.hash;
if (!hash.includes(`demo/${id}`)) return false;
const root = document.getElementById('root');
if (!root || root.innerHTML.length < 100) return false;
// If we have a marker, verify screen content too
if (markerText) {
return root.textContent?.includes(markerText) ?? false;
}
return true;
},
[expectedScreenId, marker] as [string, string | undefined],
{ timeout: 10000 },
).then(() => true).catch(() => false);
if (!appeared) {
// Gather debug info on failure
const debug = await this.appFrame!.evaluate(() => ({
hash: window.location.hash,
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
}));
expect.fail(
`Expected screen "${expectedScreenId}" but got hash="${debug.hash}", ` +
`content: "${debug.rootText}"`,
);
}
});
Then('l\'URL contient {string}', async function (this: FestipodWorld, expected: string) {
const hash = await this.appFrame!.evaluate(() => window.location.hash);
expect(hash, `URL hash should contain "${expected}"`).to.include(expected);
});
When('l\'utilisateur clique sur le bouton {string}', async function (this: FestipodWorld, buttonText: string) {
// Click button matching the text inside the app iframe
const button = this.appFrame!.locator(`button`, { hasText: buttonText });
await button.first().click();
await this.appFrame!.waitForTimeout(1000);
});
Then('l\'application affiche la galerie', async function (this: FestipodWorld) {
const appeared = await this.appFrame!.waitForFunction(
() => {
const root = document.getElementById('root');
if (!root) return false;
const hash = window.location.hash;
// Gallery is at #/ or empty hash
const isGalleryHash = hash === '#/' || hash === '' || hash === '#';
// Gallery shows screen thumbnails — look for "Tous les écrans" or screen grid
const isGalleryContent = root.textContent?.includes('Wireframe') ?? false;
return isGalleryHash || isGalleryContent;
},
{ timeout: 10000 },
).then(() => true).catch(() => false);
if (!appeared) {
const debug = await this.appFrame!.evaluate(() => ({
hash: window.location.hash,
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
}));
expect.fail(
`Expected Gallery but got hash="${debug.hash}", content: "${debug.rootText}"`,
);
}
});
@@ -0,0 +1,30 @@
import { Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
Then('l\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {
const source = this.getRenderedText();
// LoginScreen must have a useEffect that navigates on status === 'connected'
const hasAutoNavigate =
source.includes('useEffect') &&
source.includes("status === 'connected'") &&
source.includes("navigate") &&
source.includes("'home'");
expect(hasAutoNavigate, 'LoginScreen should auto-navigate to home when status becomes connected').to.be.true;
});
Then('l\'écran gère l\'état de connexion en cours', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const hasConnectingState =
source.includes("status === 'connecting'") ||
source.includes("Connexion NextGraph en cours");
expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;
});
Then('l\'écran n\'importe pas de données de démonstration', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const importsSeedData = source.includes('seedData') || source.includes('seedEvents');
const usesFestipodData = source.includes('useFestipodData');
expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;
expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;
});
@@ -8,14 +8,20 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup ---
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
const exists = await this.appFrame!.evaluate(
// Ensure wallet has data (seed if empty)
await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
if (td.events.size === 0) td.loadTestData();
});
// Wait for event to appear
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
return [...td.events].some((e: any) => e.title === title);
},
eventTitle,
{ timeout: 10000 },
);
expect(exists, `Event "${eventTitle}" should exist in the data layer`).to.be.true;
});
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {