Data-layer BDD testing infrastructure and steps/frontend → steps/ui rename

- Rename steps/frontend/ to steps/ui/ across all modules and shared
- Add data-layer test harness (mock + real broker modes) with Playwright
- Add inscription data-layer steps (@data scenarios)
- Add test auth setup script and browser debug script
- Update docs (architecture, BDD testing, data-layer testing)
- Add ADR for headless wallet creation decision

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-03-12 17:56:48 +01:00
parent 901fd659df
commit 6f9b3ece34
26 changed files with 1721 additions and 9869 deletions
@@ -9,22 +9,56 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
# --- UI ---
Scénario: Consulter un événement avant inscription
Étant donné que je suis sur la page "détail événement"
Alors l'écran affiche les informations de l'événement
Scénario: S'inscrire à un événement
* Scénario non implémenté
Scénario: Se désinscrire d'un événement
* Scénario non implémenté
Scénario: Voir le bouton d'inscription sur l'écran
Étant donné que je suis sur la page "détail événement"
Alors je peux m'inscrire à l'événement
Scénario: Rechercher un événement existant
Étant donné que je suis sur la page "découvrir"
Alors je peux voir la liste des événements
Scénario: Vérifier les données de l'écran
* Scénario non implémenté
# --- Data ---
Scénario: Rechercher dans une base existante (Mobilizon)
* Scénario non implémenté
@data
Scénario: S'inscrire à un événement
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
Et l'événement "Formation CNV" a 8 participants au départ
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
Alors l'utilisateur est participant de l'événement "Formation CNV"
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
Et l'événement "Formation CNV" compte 9 participants
@data
Scénario: Se désinscrire d'un événement
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
Et l'événement "Résidence Reconnexion" a 12 participants au départ
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion"
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
Et l'événement "Résidence Reconnexion" compte 11 participants
@data
Scénario: L'inscription est idempotente
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
Et l'événement "Résidence Reconnexion" a 12 participants au départ
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
Et l'événement "Résidence Reconnexion" compte 12 participants
@data
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
Et l'événement "Formation CNV" a 8 participants au départ
Quand l'utilisateur se désinscrit de l'événement "Formation CNV"
Alors l'utilisateur n'est plus participant de l'événement "Formation CNV"
Et l'événement "Formation CNV" compte 8 participants
@@ -0,0 +1,166 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Data-layer steps: operate via Playwright + window.__testData bridge.
// The harness exposes DeepSignalSets and helper methods directly.
// --- Setup ---
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
const exists = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
return [...td.events].some((e: any) => e.title === title);
},
eventTitle,
);
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) {
await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
await this.appFrame!.evaluate(
([title, c]: [string, number]) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.updateEvent(event['@id'], { participantCount: c });
},
[eventTitle, count] as [string, number],
);
});
// --- Actions ---
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
// --- Assertions ---
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const participating = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
},
eventTitle,
);
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const participating = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
},
eventTitle,
);
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
});
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
return event?.participantCount ?? -1;
},
eventTitle,
);
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
});
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const found = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
},
eventTitle,
);
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const found = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
},
eventTitle,
);
expect(found, `User should NOT appear in participants of "${eventTitle}"`).to.be.false;
});
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
},
eventTitle,
);
expect(count, 'User should have exactly one participation record').to.equal(1);
});
+134 -12
View File
@@ -134,14 +134,18 @@ export const parsedFeatures: ParsedFeature[] = [
]
},
{
"name": "S'inscrire à un événement",
"name": "Voir le bouton d'inscription sur l'écran",
"tags": [],
"steps": []
},
{
"name": "Se désinscrire d'un événement",
"tags": [],
"steps": []
"steps": [
{
"keyword": "Étant donné que ",
"text": "je suis sur la page \"détail événement\""
},
{
"keyword": "Alors",
"text": "je peux m'inscrire à l'événement"
}
]
},
{
"name": "Rechercher un événement existant",
@@ -158,18 +162,136 @@ export const parsedFeatures: ParsedFeature[] = [
]
},
{
"name": "Vérifier les données de l'écran",
"name": "S'inscrire à un événement",
"tags": [],
"steps": []
"steps": [
{
"keyword": "Étant donné",
"text": "un événement \"Formation CNV\" existe"
},
{
"keyword": "Et",
"text": "l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\""
},
{
"keyword": "Et",
"text": "l'événement \"Formation CNV\" a 8 participants au départ"
},
{
"keyword": "Quand",
"text": "l'utilisateur s'inscrit à l'événement \"Formation CNV\""
},
{
"keyword": "Alors",
"text": "l'utilisateur est participant de l'événement \"Formation CNV\""
},
{
"keyword": "Et",
"text": "l'utilisateur apparaît dans la liste des participants de l'événement \"Formation CNV\""
},
{
"keyword": "Et",
"text": "l'événement \"Formation CNV\" compte 9 participants"
}
]
},
{
"name": "Rechercher dans une base existante (Mobilizon)",
"name": "Se désinscrire d'un événement",
"tags": [],
"steps": []
"steps": [
{
"keyword": "Étant donné",
"text": "un événement \"Résidence Reconnexion\" existe"
},
{
"keyword": "Et",
"text": "l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Et",
"text": "l'événement \"Résidence Reconnexion\" a 12 participants au départ"
},
{
"keyword": "Quand",
"text": "l'utilisateur se désinscrit de l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Alors",
"text": "l'utilisateur n'est plus participant de l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Et",
"text": "l'utilisateur n'apparaît plus dans la liste des participants de l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Et",
"text": "l'événement \"Résidence Reconnexion\" compte 11 participants"
}
]
},
{
"name": "L'inscription est idempotente",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "un événement \"Résidence Reconnexion\" existe"
},
{
"keyword": "Et",
"text": "l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Et",
"text": "l'événement \"Résidence Reconnexion\" a 12 participants au départ"
},
{
"keyword": "Quand",
"text": "l'utilisateur essaie de s'inscrire une seconde fois à l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Alors",
"text": "l'inscription est idempotente pour l'événement \"Résidence Reconnexion\""
},
{
"keyword": "Et",
"text": "l'événement \"Résidence Reconnexion\" compte 12 participants"
}
]
},
{
"name": "Se désinscrire d'un événement auquel on n'est pas inscrit",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "un événement \"Formation CNV\" existe"
},
{
"keyword": "Et",
"text": "l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\""
},
{
"keyword": "Et",
"text": "l'événement \"Formation CNV\" a 8 participants au départ"
},
{
"keyword": "Quand",
"text": "l'utilisateur se désinscrit de l'événement \"Formation CNV\""
},
{
"keyword": "Alors",
"text": "l'utilisateur n'est plus participant de l'événement \"Formation CNV\""
},
{
"keyword": "Et",
"text": "l'événement \"Formation CNV\" compte 8 participants"
}
]
}
],
"filePath": "src/modules/event/features/us-7-inscription-evenement.feature",
"rawContent": "# language: fr\n@EVENT @priority-1\nFonctionnalité: US-7 M'inscrire/me désinscrire à un événement\n En tant qu'utilisateur\n Je peux m'inscrire/me désinscrire à un événement\n Après avoir consulté la description de l'événement, les dates et le lieu\n S'il existe déjà dans le système ou en le retrouvant dans une base existante\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Consulter un événement avant inscription\n Étant donné que je suis sur la page \"détail événement\"\n Alors l'écran affiche les informations de l'événement\n\n Scénario: S'inscrire à un événement\n * Scénario non implémenté\n\n Scénario: Se désinscrire d'un événement\n * Scénario non implémenté\n\n Scénario: Rechercher un événement existant\n Étant donné que je suis sur la page \"découvrir\"\n Alors je peux voir la liste des événements\n\n Scénario: Vérifier les données de l'écran\n * Scénario non implémenté\n\n Scénario: Rechercher dans une base existante (Mobilizon)\n * Scénario non implémenté\n",
"rawContent": "# language: fr\n@EVENT @priority-1\nFonctionnalité: US-7 M'inscrire/me désinscrire à un événement\n En tant qu'utilisateur\n Je peux m'inscrire/me désinscrire à un événement\n Après avoir consulté la description de l'événement, les dates et le lieu\n S'il existe déjà dans le système ou en le retrouvant dans une base existante\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n # --- UI ---\n\n Scénario: Consulter un événement avant inscription\n Étant donné que je suis sur la page \"détail événement\"\n Alors l'écran affiche les informations de l'événement\n\n Scénario: Voir le bouton d'inscription sur l'écran\n Étant donné que je suis sur la page \"détail événement\"\n Alors je peux m'inscrire à l'événement\n\n Scénario: Rechercher un événement existant\n Étant donné que je suis sur la page \"découvrir\"\n Alors je peux voir la liste des événements\n\n # --- Data ---\n\n @data\n Scénario: S'inscrire à un événement\n Étant donné un événement \"Formation CNV\" existe\n Et l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" a 8 participants au départ\n Quand l'utilisateur s'inscrit à l'événement \"Formation CNV\"\n Alors l'utilisateur est participant de l'événement \"Formation CNV\"\n Et l'utilisateur apparaît dans la liste des participants de l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" compte 9 participants\n\n @data\n Scénario: Se désinscrire d'un événement\n Étant donné un événement \"Résidence Reconnexion\" existe\n Et l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" a 12 participants au départ\n Quand l'utilisateur se désinscrit de l'événement \"Résidence Reconnexion\"\n Alors l'utilisateur n'est plus participant de l'événement \"Résidence Reconnexion\"\n Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" compte 11 participants\n\n @data\n Scénario: L'inscription est idempotente\n Étant donné un événement \"Résidence Reconnexion\" existe\n Et l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" a 12 participants au départ\n Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement \"Résidence Reconnexion\"\n Alors l'inscription est idempotente pour l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" compte 12 participants\n\n @data\n Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit\n Étant donné un événement \"Formation CNV\" existe\n Et l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" a 8 participants au départ\n Quand l'utilisateur se désinscrit de l'événement \"Formation CNV\"\n Alors l'utilisateur n'est plus participant de l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" compte 8 participants\n",
"screenIds": [
"event-detail",
"events"
+282 -8
View File
@@ -1,11 +1,181 @@
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { execSync } from 'child_process';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import type { FestipodWorld } from './world';
BeforeAll(async function () {
setDefaultTimeout(30000);
let browser: Browser;
let browserContext: BrowserContext;
// Harness paths
const HARNESS_ENTRY = 'src/shared/test-harness/harness.tsx';
const HARNESS_OUT = path.join('dist', 'test-harness.js');
const HARNESS_NG_ENTRY = 'src/shared/test-harness/harness-ng.tsx';
const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
// Persistent Chromium profile for NG wallet (not the user's daily browser)
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
let harnessServer: http.Server | null = null;
let harnessPort = 0;
let useRealBroker = false;
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
/**
* Automated wallet creation + login on nextgraph.eu.
* Flow:
* 1. Navigate to https://nextgraph.eu/ → "Create Wallet" button
* 2. Click → redirects to account.nextgraph.eu → "I accept" (ToS)
* 3. Click → redirects back → username/password form → fill & submit
* 4. Wallet created → saved in localStorage (shared with nextgraph.eu/auth/)
*
* This is also a legitimate test of the app's auth/login feature.
*/
async function ensureAuth(): Promise<void> {
const markerPath = path.join(PLAYWRIGHT_PROFILE, '.wallet-ready');
if (fs.existsSync(markerPath)) {
console.log('[Auth] Wallet found in persistent profile — skipping creation');
return;
}
console.log('[Auth] No wallet — creating one automatically on nextgraph.eu...');
fs.mkdirSync(PLAYWRIGHT_PROFILE, { recursive: true });
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
const authContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
headless: true,
executablePath: chromePath.includes('headless') ? undefined : chromePath,
});
const page = authContext.pages()[0] || await authContext.newPage();
page.on('pageerror', () => {});
try {
// Step 1: Navigate to nextgraph.eu — shows NoWallet page
console.log('[Auth] Step 1: Loading nextgraph.eu...');
await page.goto('https://nextgraph.eu/', { waitUntil: 'domcontentloaded', timeout: 30000 });
// Step 2: Click "Create Wallet"
console.log('[Auth] Step 2: Clicking "Create Wallet"...');
const createButton = page.getByText('Create Wallet', { exact: true });
await createButton.waitFor({ state: 'visible', timeout: 15000 });
await createButton.click();
// Step 3: Redirects to account.nextgraph.eu — accept Terms of Service
console.log('[Auth] Step 3: Accepting Terms of Service...');
await page.waitForURL('**/account*', { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText('I accept', { exact: true });
await acceptButton.waitFor({ state: 'visible', timeout: 15000 });
await acceptButton.click();
// Step 4: Redirects back to nextgraph.eu (possibly via nextgraph.net for bootstrap)
// Wait for the username input to appear (wallet creation form)
console.log('[Auth] Step 4: Filling wallet credentials...');
const usernameInput = page.locator('#username-input');
await usernameInput.waitFor({ state: 'visible', timeout: 30000 });
await usernameInput.fill(WALLET_NAME);
const passwordInput = page.locator('#password-input');
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
// Step 5: Submit — click the create button
console.log('[Auth] Step 5: Creating wallet...');
// The button text is "I create my wallet now!" — use a partial match
const submitButton = page.getByText('create my wallet', { exact: false });
await submitButton.waitFor({ state: 'visible', timeout: 5000 });
await submitButton.click();
// Step 6: Wait for wallet to be created (redirects to #/wallet/login)
console.log('[Auth] Step 6: Waiting for wallet creation to complete...');
await page.waitForURL('**/#/wallet/login', { timeout: 30000 });
// Give localStorage time to persist
await page.waitForTimeout(2000);
console.log('[Auth] Wallet created successfully');
} finally {
await authContext.close();
}
fs.writeFileSync(markerPath, new Date().toISOString());
console.log('[Auth] Wallet setup complete');
}
// 10-minute timeout for BeforeAll: wallet creation may take a while on first run
BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
console.log('Starting Festipod BDD tests...');
// Build the mock harness (always needed for non-@data or fallback)
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle`, { stdio: 'pipe' });
console.log(`[Harness] Built mock (${(fs.statSync(HARNESS_OUT).size / 1024).toFixed(0)} KB)`);
// Try to build the real broker harness
try {
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
// Ensure wallet exists in persistent profile (opens browser if needed)
await ensureAuth();
useRealBroker = true;
// Start HTTP server serving the harness HTML + JS separately
// (inline script breaks due to special characters in the bundle)
const harnessBundle = fs.readFileSync(path.resolve(HARNESS_NG_OUT), 'utf-8');
const harnessHtml = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div id="root"></div>
<script src="/harness.js"></script>
</body>
</html>`;
harnessServer = http.createServer((req, res) => {
if (req.url === '/harness.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(harnessBundle);
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(harnessHtml);
}
});
harnessPort = await new Promise<number>((resolve) => {
harnessServer!.listen(0, '127.0.0.1', () => {
resolve((harnessServer!.address() as { port: number }).port);
});
});
console.log(`[Harness] HTTP server on http://127.0.0.1:${harnessPort}`);
// Launch Chromium with the same persistent profile (has the wallet).
// - Use full Chrome binary (not chrome-headless-shell) so localStorage persists
// - Grant permissions to avoid prompts
// - Disable Private Network Access (broker at nextgraph.eu needs to load
// our local harness at http://127.0.0.1:{port} in an iframe)
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
browserContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
headless: true,
executablePath: chromePath.includes('headless') ? undefined : chromePath,
permissions: ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'],
args: [
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
'--allow-insecure-localhost',
'--disable-web-security',
],
});
console.log('[Hooks] Real broker mode ready');
} catch (err) {
console.warn(`[Hooks] NG harness build/auth failed, falling back to mock: ${err}`);
useRealBroker = false;
browser = await chromium.launch({ headless: true });
browserContext = await browser.newContext();
console.log('[Hooks] Mock mode (no broker)');
}
});
Before(async function (this: FestipodWorld, scenario) {
Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
// Reset UI-layer state
this.currentRoute = '#/';
this.currentScreenId = null;
this.formFields.clear();
@@ -14,25 +184,129 @@ Before(async function (this: FestipodWorld, scenario) {
this.screenSourceContent = '';
this.currentScreen = null;
// Skipped scenarios use the "* Scénario non implémenté" placeholder step
// which returns 'skipped' - no special handling needed in the hook
// Launch Playwright page for @data scenarios
const tags = scenario.pickle.tags.map(t => t.name);
if (tags.includes('@data')) {
this.page = await browserContext.newPage();
// Capture console for debugging
this.page.on('pageerror', (err) => console.error('[Browser error]', err.message));
this.page.on('console', (msg) => {
if (msg.type() === 'error') console.error('[Browser console]', msg.text());
});
if (useRealBroker) {
// Navigate to broker redirect — broker loads our harness in an iframe
const appUrl = `http://127.0.0.1:${harnessPort}`;
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await this.page.goto(brokerRedirect, { waitUntil: 'domcontentloaded' });
// Automate wallet login if needed
const loginButton = this.page.getByText('Login', { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await this.page.waitForURL('**/wallet/login', { timeout: 5000 }).catch(() => {});
const walletLink = this.page.getByText('Click here to login with your wallet');
await walletLink.waitFor({ state: 'visible', timeout: 5000 });
await walletLink.click();
await this.page.waitForTimeout(1000);
const passwordInput = this.page.locator('input[type="password"]');
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press('Enter');
// Wait for login to complete and app iframe to load
await this.page.waitForTimeout(3000);
}
// Verify iframe loaded after login
const iframeCount = await this.page.locator('iframe').count();
if (iframeCount === 0) {
const bodyText = await this.page.evaluate(() => document.body?.innerText?.substring(0, 300));
throw new Error(`No iframe found after login. Body: ${bodyText}`);
}
// Wait for our app iframe (broker loads it after successful auth)
let appFrame = null;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
for (const f of this.page.frames()) {
if (f.url().startsWith(appUrl) || f.url().includes('127.0.0.1')) {
appFrame = f;
break;
}
}
if (appFrame) break;
for (const iframe of await this.page.locator('iframe').all()) {
const src = await iframe.getAttribute('src');
if (src && src.includes('127.0.0.1')) {
const el = await iframe.elementHandle();
appFrame = await el?.contentFrame();
if (appFrame) break;
}
}
if (appFrame) break;
await this.page.waitForTimeout(500);
}
if (!appFrame) {
const frames = this.page.frames().map(f => f.url());
throw new Error(`App iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
}
this.appFrame = appFrame;
// Wait for NG session + useShape + bridge
await this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
{ timeout: 30000 },
);
} else {
// Mock mode: load harness directly
await this.page.setContent('<!DOCTYPE html><html><body><div id="root"></div></body></html>');
await this.page.addScriptTag({ path: path.resolve(HARNESS_OUT) });
this.appFrame = this.page.mainFrame();
await this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
{ timeout: 10000 },
);
}
}
});
After(async function (this: FestipodWorld, scenario) {
After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
if (scenario.result?.status === Status.FAILED) {
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
this.attach(`Current screen: ${this.currentScreenId}`, 'text/plain');
this.attach(`Navigation history: ${JSON.stringify(this.navigationHistory)}`, 'text/plain');
this.attach(`Form fields: ${JSON.stringify(Array.from(this.formFields.entries()))}`, 'text/plain');
if (this.screenSourceContent) {
// Show first 500 chars of source to help debug
this.attach(`Screen source (first 500 chars): ${this.screenSourceContent.substring(0, 500)}...`, 'text/plain');
}
}
// Clean up
// Close Playwright page
if (this.page) {
await this.page.close();
this.page = null;
this.appFrame = null;
}
// Clean up UI-layer
this.cleanup();
});
AfterAll(async function () {
if (browserContext) await browserContext.close();
if (browser) await browser.close();
if (harnessServer) {
await new Promise<void>((resolve) => harnessServer!.close(() => resolve()));
}
console.log('Festipod BDD tests completed.');
});
+10 -1
View File
@@ -1,5 +1,6 @@
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber';
import { getScreen, type Screen } from '../../screens/index';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
@@ -10,10 +11,14 @@ export interface FestipodWorld extends World {
navigationHistory: string[];
isAuthenticated: boolean;
// Screen analysis
// Screen analysis (UI layer)
currentScreen: Screen | null;
screenSourceContent: string;
// Playwright (data layer)
page: Page | null;
appFrame: Frame | null;
navigateTo(route: string): void;
getFormField(name: string): { required: boolean; value: string } | undefined;
getCurrentScreenFields(): string[];
@@ -224,6 +229,10 @@ class CustomWorld extends World implements FestipodWorld {
currentScreen: Screen | null = null;
screenSourceContent: string = '';
// Playwright (data layer testing)
page: Page | null = null;
appFrame: Frame | null = null;
constructor(options: IWorldOptions) {
super(options);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Minimal NG app for auth setup.
* Calls @ng-org/web init() — which redirects to broker if at top level,
* or establishes session if inside broker iframe.
* On success, POSTs the session back to the local server and saves it in localStorage.
*/
import { init, ng } from '@ng-org/web';
await init(
async (event: any) => {
const status = document.getElementById('status')!;
status.innerHTML =
'<span style="color: green; font-size: 1.5rem;">&#10003; Logged in!</span>' +
'<br><br>You can close this tab. Tests will continue automatically.';
// Send session to local server so the test runner can save it
const session = event.session;
try {
await fetch('/auth-done', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: session.session_id,
private_store_id: session.private_store_id,
protected_store_id: session.protected_store_id,
public_store_id: session.public_store_id,
}),
});
} catch (e) {
console.error('[auth-setup] Failed to notify server:', e);
}
},
true,
[],
);
+209
View File
@@ -0,0 +1,209 @@
/**
* Data-layer test harness (real NextGraph broker mode).
*
* Runs inside an iframe controlled by the NextGraph broker.
* Uses real @ng-org/web init + @ng-org/orm useShape to test
* the full data pipeline up to the broker.
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { ng, init as initNgWeb } from '@ng-org/web';
import { initNg as initNgSignals } from '@ng-org/orm';
import { useShape } from '@ng-org/orm/react';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Seed data — injected into the broker if the graph is empty
// ============================================================================
const SEED_EVENTS: Array<Omit<FpEvent, '@graph' | '@id'>> = [
{ '@type': 'http://festipod.org/Event', title: 'Résidence Reconnexion', date: 'Lun. 16 - Ven. 20 fév.', location: 'Écocentre de Villarceaux', participantCount: 24 },
{ '@type': 'http://festipod.org/Event', title: 'Marché des créateurs', date: 'Sam. 22 fév. · 10h', location: 'Place Bellecour, Lyon', participantCount: 12 },
{ '@type': 'http://festipod.org/Event', title: 'Cercle de parole', date: 'Dim. 23 fév. · 14h', location: 'Maison des associations', participantCount: 45 },
{ '@type': 'http://festipod.org/Event', title: 'Formation CNV', date: 'Sam. 1 mars · 9h30', location: 'Centre Iris, Paris', participantCount: 16 },
{ '@type': 'http://festipod.org/Event', title: 'Festival Printemps', date: 'Ven. 14 - Dim. 16 mars', location: 'Domaine de Longchamp', participantCount: 30 },
];
// ============================================================================
// NG initialization
// ============================================================================
let sessionReady = false;
let ngSession: any = null;
async function initNG() {
console.log('[HarnessNG] Initializing NextGraph...');
await initNgWeb(
async (event: any) => {
ngSession = event.session;
ngSession.ng ??= ng;
initNgSignals(ng, ngSession);
sessionReady = true;
console.log('[HarnessNG] NG session established, private_store_id:', ngSession.private_store_id);
},
true, // singleton
[], // access_requests
);
}
// Start init immediately (this will postMessage to parent broker)
initNG().catch((err) => {
console.error('[HarnessNG] Init failed:', err);
// Signal failure so tests don't hang
(window as any).__testData = { ready: false, error: err.message };
});
// ============================================================================
// Harness component — uses real useShape, exposes window.__testData
// ============================================================================
function DataHarnessNG() {
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
// Wait for NG session before rendering useShape hooks
if (!sessionReady) {
return <WaitForSession onReady={() => setReady(true)} onError={setError} />;
}
return <ShapeConsumer />;
}
function WaitForSession({ onReady, onError }: { onReady: () => void; onError: (e: string) => void }) {
useEffect(() => {
const interval = setInterval(() => {
if (sessionReady) {
clearInterval(interval);
onReady();
}
}, 100);
const timeout = setTimeout(() => {
clearInterval(interval);
if (!sessionReady) {
onError('NG session timeout after 30s');
}
}, 30000);
return () => { clearInterval(interval); clearTimeout(timeout); };
}, []);
return <div id="harness-status">WAITING_FOR_SESSION</div>;
}
function ShapeConsumer() {
// Use "did:ng:i" scope = all data in private store (same as the app)
const events = useShape(FpEventShapeType, 'did:ng:i') as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, 'did:ng:i') as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, 'did:ng:i') as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
useEffect(() => {
// Wait a tick for useShape to populate
const timer = setTimeout(() => {
const graph = `did:ng:${ngSession.private_store_id}`;
// Seed events if the graph is empty
if (events.size === 0) {
console.log('[HarnessNG] Graph empty, seeding test events...');
for (const e of SEED_EVENTS) {
events.add({ ...e, '@graph': graph, '@id': '' } as FpEvent);
}
}
// Seed a test user if none exists
if (users.size === 0) {
console.log('[HarnessNG] No user profile, seeding test user...');
users.add({
'@graph': graph,
'@id': '',
'@type': 'http://festipod.org/UserProfile',
name: 'Test User',
initials: 'TU',
username: '@testuser',
} as FpUserProfile);
}
// Get current user ID (wait briefly for NG to assign @id)
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Expose the test bridge
(window as any).__testData = {
ready: true,
events,
users,
participations,
currentUserId,
session: ngSession,
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
},
joinEvent(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
participations.add({
'@graph': `did:ng:${ngSession.private_store_id}`,
'@type': 'http://festipod.org/Participation',
'@id': '', // auto-assigned by NG
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
}
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size, 'participations:', participations.size);
setBridgeReady(true);
}, 500); // Small delay for useShape to populate
return () => clearTimeout(timer);
}, [events, users, participations]);
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
}
// ============================================================================
// Bootstrap
// ============================================================================
const root = createRoot(document.getElementById('root')!);
root.render(<DataHarnessNG />);
+158
View File
@@ -0,0 +1,158 @@
/**
* Data-layer test harness (mock mode).
*
* Uses DeepSignalSet directly — the same reactive data structure that
* useShape returns — seeded with test data. No NextGraph broker required.
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*
* When a real broker is available, swap this for harness-ng.tsx.
*/
import React, { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { deepSignal } from '@ng-org/alien-deepsignals';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Seed data — same events/users as the app's seedData.ts
// ============================================================================
const GRAPH = 'did:ng:test';
function seedEvents(): DeepSignalSet<FpEvent> {
const set = deepSignal(new Set<FpEvent>()) as DeepSignalSet<FpEvent>;
const events: Array<Omit<FpEvent, '@graph' | '@id'>> = [
{ '@type': 'http://festipod.org/Event', title: 'Résidence Reconnexion', date: 'Lun. 16 - Ven. 20 fév.', location: 'Écocentre de Villarceaux', participantCount: 24 },
{ '@type': 'http://festipod.org/Event', title: 'Marché des créateurs', date: 'Sam. 22 fév. · 10h', location: 'Place Bellecour, Lyon', participantCount: 12 },
{ '@type': 'http://festipod.org/Event', title: 'Cercle de parole', date: 'Dim. 23 fév. · 14h', location: 'Maison des associations', participantCount: 45 },
{ '@type': 'http://festipod.org/Event', title: 'Formation CNV', date: 'Sam. 1 mars · 9h30', location: 'Centre Iris, Paris', participantCount: 16 },
{ '@type': 'http://festipod.org/Event', title: 'Festival Printemps', date: 'Ven. 14 - Dim. 16 mars', location: 'Domaine de Longchamp', participantCount: 30 },
];
for (const e of events) {
set.add({ ...e, '@graph': GRAPH, '@id': `event:${e.title}` } as FpEvent);
}
return set;
}
function seedUsers(): DeepSignalSet<FpUserProfile> {
const set = deepSignal(new Set<FpUserProfile>()) as DeepSignalSet<FpUserProfile>;
const users: Array<Omit<FpUserProfile, '@graph' | '@id'>> = [
{ '@type': 'http://festipod.org/UserProfile', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
{ '@type': 'http://festipod.org/UserProfile', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
{ '@type': 'http://festipod.org/UserProfile', name: 'Thomas Martin', initials: 'TM', username: '@thomasmartin' },
];
for (const u of users) {
set.add({ ...u, '@graph': GRAPH, '@id': `user:${u.username}` } as FpUserProfile);
}
return set;
}
function seedParticipations(
events: DeepSignalSet<FpEvent>,
users: DeepSignalSet<FpUserProfile>,
): DeepSignalSet<FpParticipation> {
const set = deepSignal(new Set<FpParticipation>()) as DeepSignalSet<FpParticipation>;
const marie = [...users].find(u => u.username === '@mariedupont')!;
const jean = [...users].find(u => u.username === '@jeandurand')!;
const thomas = [...users].find(u => u.username === '@thomasmartin')!;
const ev1 = [...events].find(e => e.title === 'Résidence Reconnexion')!;
const ev2 = [...events].find(e => e.title === 'Marché des créateurs')!;
const ev3 = [...events].find(e => e.title === 'Cercle de parole')!;
// Marie participates in events 1, 2, 3
for (const ev of [ev1, ev2, ev3]) {
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${marie['@id']}:${ev['@id']}`, event: ev['@id'], user: marie['@id'], isConfirmed: true } as FpParticipation);
}
// Jean participates in event 1
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${jean['@id']}:${ev1['@id']}`, event: ev1['@id'], user: jean['@id'], isConfirmed: true } as FpParticipation);
// Thomas participates in event 1
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${thomas['@id']}:${ev1['@id']}`, event: ev1['@id'], user: thomas['@id'], isConfirmed: true } as FpParticipation);
return set;
}
// ============================================================================
// Harness component — exposes window.__testData
// ============================================================================
function DataHarness() {
const eventsRef = useRef(seedEvents());
const usersRef = useRef(seedUsers());
const participationsRef = useRef(seedParticipations(eventsRef.current, usersRef.current));
const events = eventsRef.current;
const users = usersRef.current;
const participations = participationsRef.current;
// Current user = first user (Marie)
const currentUser = [...users][0];
useEffect(() => {
(window as any).__testData = {
ready: true,
events,
users,
participations,
currentUserId: currentUser?.['@id'] || '',
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
},
joinEvent(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
participations.add({
'@graph': GRAPH,
'@type': 'http://festipod.org/Participation',
'@id': `part:${userId}:${eventId}:${Date.now()}`,
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
}
},
clearAll() {
for (const p of [...participations]) participations.delete(p);
for (const e of [...events]) events.delete(e);
for (const u of [...users]) users.delete(u);
},
};
console.log('[TestHarness] Ready — events:', events.size, 'users:', users.size, 'participations:', participations.size);
}, []);
return <div id="harness-status">READY</div>;
}
// ============================================================================
// Bootstrap
// ============================================================================
const root = createRoot(document.getElementById('root')!);
root.render(<DataHarness />);