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:
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user