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
+50 -94
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import type {
FpEventData,
FpUserData,
@@ -17,13 +17,13 @@ import {
import { useNextGraph } from './NextGraphContext';
import { sessionPromise } from '../utils/ngSession';
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
import { bootstrapWallet } from '../utils/ngBootstrap';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
// ============================================================================
// Context interface
@@ -61,6 +61,7 @@ interface FestipodDataContextValue {
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
loadTestData(): Promise<BootstrapResult>;
}
const FestipodDataContext = createContext<FestipodDataContextValue | null>(null);
@@ -160,24 +161,25 @@ function buildQueries(
// Local (disconnected) provider — uses seed data directly, read-only
// ============================================================================
function useLocalData(): FestipodDataContextValue {
const [selectedEventId, setSelectedEventId] = useState<string>('event-1');
function useLocalData(empty?: boolean): FestipodDataContextValue {
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
const events = seedEvents;
const users = seedUsers;
const participations = seedParticipations;
const meetingPoints = seedMeetingPoints;
const friendships = seedFriendships;
const events = empty ? [] : seedEvents;
const users = empty ? [] : seedUsers;
const participations = empty ? [] : seedParticipations;
const meetingPoints = empty ? [] : seedMeetingPoints;
const friendships = empty ? [] : seedFriendships;
const currentUserId = CURRENT_USER_ID;
const currentUserId = empty ? '' : CURRENT_USER_ID;
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
console.log('[FestipodData] Render — local | events:', events.length,
console.log('[FestipodData] Render —', empty ? 'connecting (empty)' : 'local',
'| events:', events.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// Local mode: mutations are no-ops (static defaults)
@@ -203,6 +205,10 @@ function useLocalData(): FestipodDataContextValue {
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (local, no-op)');
}, []);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (local, no-op)');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}, []);
return {
currentUserId, currentUser,
@@ -211,101 +217,41 @@ function useLocalData(): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
}
// ============================================================================
// NextGraph-connected provider — uses useShapeWithDefaults + bootstrap
// NextGraph-connected provider — reads directly from NG wallet
// ============================================================================
function useNgData(): FestipodDataContextValue {
const [shapesReady, setShapesReady] = useState(false);
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
// useShapeWithDefaults calls useShape internally (only safe when NG is connected)
const eventsShape = useShapeWithDefaults(FpEventShapeType, seedEvents, mapEvent, shapesReady);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, seedUsers, mapUser, shapesReady);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, seedParticipations, mapParticipation, shapesReady);
const eventsShape = useShapeWithDefaults(FpEventShapeType, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const users = usersShape.items;
const participations = participationsShape.items;
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>(seedMeetingPoints);
const [friendships, setFriendships] = useState<FpFriendshipData[]>(seedFriendships);
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
// --- Bootstrap: detect when NG data is available, seed if first time ---
const bootstrapDone = useRef(false);
const bootstrapInProgress = useRef(false);
// Reactive detection: when ngSet gets populated, NG data has arrived (returning user)
// Skip if bootstrap is currently seeding (to avoid race condition)
// Auto-select first event when data appears from NG
useEffect(() => {
if (shapesReady || bootstrapDone.current || bootstrapInProgress.current) return;
const evSize = eventsShape.ngSet.size;
const uSize = usersShape.ngSet.size;
const pSize = participationsShape.ngSet.size;
console.log('[FestipodData] Checking ngSet sizes — events:', evSize, 'users:', uSize, 'participations:', pSize);
if (evSize > 0 && uSize > 0) {
console.log('[FestipodData] NG data fully loaded — returning user, marking ready');
bootstrapDone.current = true;
setShapesReady(true);
// Auto-select first event
const first = [...eventsShape.ngSet][0];
if (first) {
console.log('[FestipodData] Selecting first event:', first.title, first["@id"]);
setSelectedEventId(first["@id"]);
}
if (!selectedEventId && events.length > 0) {
setSelectedEventId(events[0].id);
}
});
// Timeout fallback: if ngSet stays empty, assume empty wallet → bootstrap seed data
useEffect(() => {
if (bootstrapDone.current) return;
const timer = setTimeout(async () => {
if (bootstrapDone.current) return;
// Lock so reactive effect doesn't fire during seeding
bootstrapInProgress.current = true;
console.log('[FestipodData] Timeout reached — checking if seeding needed...');
console.log('[FestipodData] ngSet sizes — events:', eventsShape.ngSet.size,
'users:', usersShape.ngSet.size, 'participations:', participationsShape.ngSet.size);
// If data arrived while we waited, just mark ready
if (eventsShape.ngSet.size > 0 && usersShape.ngSet.size > 0) {
console.log('[FestipodData] Data arrived before timeout — marking ready');
bootstrapDone.current = true;
bootstrapInProgress.current = false;
setShapesReady(true);
const first = [...eventsShape.ngSet][0];
if (first) setSelectedEventId(first["@id"]);
return;
}
console.log('[FestipodData] Wallet empty — seeding...');
const result = await bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
bootstrapDone.current = true;
bootstrapInProgress.current = false;
setShapesReady(true);
if (result.seeded) {
const firstIri = result.eventIdMap.get('event-1');
if (firstIri) {
console.log('[FestipodData] Bootstrap done, selecting first event:', firstIri);
setSelectedEventId(firstIri);
}
}
}, 3000);
return () => clearTimeout(timer);
}, []);
}, [events.length, selectedEventId]);
// --- Derived ---
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
@@ -382,11 +328,7 @@ function useNgData(): FestipodDataContextValue {
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid,
'| ngSet sizes — events:', eventsShape.ngSet.size,
'users:', usersShape.ngSet.size,
'participations:', participationsShape.ngSet.size,
'| bootstrapDone:', bootstrapDone.current);
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
console.log('[FestipodData] Deleting participation:', ngPart["@id"]);
@@ -425,6 +367,15 @@ function useNgData(): FestipodDataContextValue {
}
}, [usersShape.ngSet]);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
return bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
return {
currentUserId, currentUser,
events, users, participations, meetingPoints, friendships,
@@ -432,7 +383,7 @@ function useNgData(): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
}
@@ -440,8 +391,8 @@ function useNgData(): FestipodDataContextValue {
// Provider — switches between local and NG
// ============================================================================
function LocalDataProvider({ children }: { children: ReactNode }) {
const data = useLocalData();
function LocalDataProvider({ children, empty }: { children: ReactNode; empty?: boolean }) {
const data = useLocalData(empty);
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
}
@@ -457,6 +408,11 @@ export function FestipodDataProvider({ children }: { children: ReactNode }) {
if (status === 'connected') {
return <NgDataProvider>{children}</NgDataProvider>;
}
if (status === 'connecting') {
// NG initializing: show empty state (no misleading seed data flash)
return <LocalDataProvider empty>{children}</LocalDataProvider>;
}
// Disconnected or error: demo mode with seed data
return <LocalDataProvider>{children}</LocalDataProvider>;
}
+10 -6
View File
@@ -20,18 +20,21 @@ const NextGraphContext = createContext<NextGraphContextValue>({
// Track whether initNg() has been called (module-level to survive re-renders)
let ngInitStarted = false;
// Detect if we're running inside the NG broker iframe
const isInsideBroker = typeof window !== 'undefined' && window.self !== window.top;
export function NextGraphProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<NgStatus>(session ? 'connected' : 'disconnected');
const [ngSession, setNgSession] = useState<NextGraphSession | undefined>(session);
const [error, setError] = useState<string | undefined>();
// Auto-init on mount: register the initNgWeb callback so we catch the
// auto-connect event from the NG iframe. This must happen early.
// Auto-init ONLY when running inside the broker iframe.
// Outside the broker, initNgWeb() would redirect the page — wait for explicit connect().
useEffect(() => {
if (ngInitStarted) return;
if (!isInsideBroker || ngInitStarted) return;
ngInitStarted = true;
console.log('[NG] Auto-init: calling initNg() on mount');
console.log('[NG] Inside broker iframe — auto-init');
setStatus('connecting');
initNg();
@@ -52,7 +55,8 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
});
}, []);
// connect() is now just a fallback — initNg() already started on mount
// connect(): called by the user clicking "Se connecter".
// When outside the broker, initNgWeb() will redirect to the broker.
const connect = useCallback(() => {
if (status === 'connecting' || status === 'connected') return;
@@ -60,7 +64,7 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
setStatus('connecting');
setError(undefined);
// initNg() is idempotent (initNgWeb handles multiple calls)
ngInitStarted = true;
initNg();
sessionPromise
+150
View File
@@ -615,6 +615,156 @@ export const parsedFeatures: ParsedFeature[] = [
"meeting-points"
]
},
{
"id": "connexion-nextgraph",
"name": "Connexion NextGraph et chargement des données",
"description": "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",
"tags": [
"@AUTH",
"@priority-1"
],
"category": "UNKNOWN",
"priority": 1,
"scenarios": [
{
"name": "L'écran de connexion affiche le bouton NextGraph",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran contient un bouton \"Se connecter avec NextGraph\""
}
]
},
{
"name": "L'écran de connexion redirige automatiquement quand connecté",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran gère la redirection automatique après connexion"
}
]
},
{
"name": "L'état initial est \"en cours\" quand une connexion est en attente",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran gère l'état de connexion en cours"
}
]
},
{
"name": "Aucune donnée de démonstration n'est visible pendant la connexion",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran n'importe pas de données de démonstration"
}
]
},
{
"name": "Un portefeuille connecté est vide par défaut",
"tags": [],
"steps": [
{
"keyword": "Alors",
"text": "le portefeuille est connecté"
},
{
"keyword": "Et",
"text": "le portefeuille ne contient aucun événement de démonstration"
}
]
},
{
"name": "Charger les données de test dans le portefeuille",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille est vide"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "le portefeuille contient des événements"
},
{
"keyword": "Et",
"text": "le portefeuille contient des utilisateurs"
}
]
},
{
"name": "Les données de test ne sont pas rechargées si le portefeuille contient déjà des données",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille contient déjà des événements"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "le nombre d'événements n'a pas changé"
}
]
},
{
"name": "Les données du portefeuille sont distinctes des données par défaut",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille est vide"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "les événements ont des identifiants NextGraph"
},
{
"keyword": "Et",
"text": "les utilisateurs ont des identifiants NextGraph"
}
]
}
],
"filePath": "src/modules/auth/features/connexion-nextgraph.feature",
"rawContent": "# language: fr\n@AUTH @priority-1\nFonctionnalité: Connexion NextGraph et chargement des données\n En tant qu'utilisateur\n Je peux me connecter à mon portefeuille NextGraph\n Et charger les données de test dans mon portefeuille\n Afin d'utiliser l'application avec mes propres données\n\n # --- UI layer: écran de connexion ---\n\n @ui\n Scénario: L'écran de connexion affiche le bouton NextGraph\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran contient un bouton \"Se connecter avec NextGraph\"\n\n @ui\n Scénario: L'écran de connexion redirige automatiquement quand connecté\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère la redirection automatique après connexion\n\n @ui\n Scénario: L'état initial est \"en cours\" quand une connexion est en attente\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère l'état de connexion en cours\n\n @ui\n Scénario: Aucune donnée de démonstration n'est visible pendant la connexion\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran n'importe pas de données de démonstration\n\n # --- Data layer: comportement du portefeuille ---\n\n @data\n Scénario: Un portefeuille connecté est vide par défaut\n Alors le portefeuille est connecté\n Et le portefeuille ne contient aucun événement de démonstration\n\n @data\n Scénario: Charger les données de test dans le portefeuille\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors le portefeuille contient des événements\n Et le portefeuille contient des utilisateurs\n\n @data\n Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données\n Étant donné que le portefeuille contient déjà des événements\n Quand je charge les données de test\n Alors le nombre d'événements n'a pas changé\n\n @data\n Scénario: Les données du portefeuille sont distinctes des données par défaut\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors les événements ont des identifiants NextGraph\n Et les utilisateurs ont des identifiants NextGraph\n",
"screenIds": [
"login"
]
},
{
"id": "us-23",
"name": "US-23 Me connecter avec d'autres utilisateurs",
+182
View File
@@ -10,6 +10,188 @@ export interface StepDefinitionInfo {
}
export const stepDefinitions: StepDefinitionInfo[] = [
{
"pattern": "le portefeuille est vide",
"keyword": "Given",
"file": "connexion.steps.ts",
"sourceCode": "Given('le portefeuille est vide', async function (this: FestipodWorld) {\n // Verify starting state: the harness graph should have its own seeded data.\n // We clear events/users/participations to simulate a truly empty wallet.\n await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n // Delete all events\n for (const e of [...td.events]) td.events.delete(e);\n // Delete all users\n for (const u of [...td.users]) td.users.delete(u);\n // Delete all participations\n for (const p of [...td.participations]) td.participations.delete(p);\n });",
"lineNumber": 10
},
{
"pattern": "le portefeuille contient déjà des événements",
"keyword": "Given",
"file": "connexion.steps.ts",
"sourceCode": "Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 32
},
{
"pattern": "je charge les données de test",
"keyword": "When",
"file": "connexion.steps.ts",
"sourceCode": "When('je charge les données de test', async function (this: FestipodWorld) {\n // Store count before loading for the idempotency test\n const countBefore = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 53
},
{
"pattern": "le portefeuille est connecté",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille est connecté', async function (this: FestipodWorld) {\n const hasSession = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.session !== undefined && td.session !== null;\n });",
"lineNumber": 81
},
{
"pattern": "le portefeuille ne contient aucun événement de démonstration",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille ne contient aucun événement de démonstration', async function (this: FestipodWorld) {\n // Wallet should be empty — no auto-seeding.\n // Also verify through the app's data context (same view as screens).\n const result = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return {\n walletEvents: td.events.size,\n appEvents: td.appData?.events?.length ?? -1,\n ngStatus: td.ngStatus,\n };\n });",
"lineNumber": 89
},
{
"pattern": "le portefeuille contient des événements",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille contient des événements', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 106
},
{
"pattern": "le portefeuille contient des utilisateurs",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille contient des utilisateurs', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.users.size;\n });",
"lineNumber": 114
},
{
"pattern": "le nombre d'événements n'a pas changé",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le nombre d\\'événements n\\'a pas changé', async function (this: FestipodWorld) {\n const countBefore = (this as any)._eventCountBefore as number;\n const countAfter = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 122
},
{
"pattern": "les événements ont des identifiants NextGraph",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('les événements ont des identifiants NextGraph', async function (this: FestipodWorld) {\n const ids = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return [...td.events].map((e: any) => e['@id']);\n });",
"lineNumber": 131
},
{
"pattern": "les utilisateurs ont des identifiants NextGraph",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {\n const ids = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return [...td.users].map((u: any) => u['@id']);\n });",
"lineNumber": 142
},
{
"pattern": "l'écran gère la redirection automatique après connexion",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n // LoginScreen must have a useEffect that navigates on status === 'connected'\n const hasAutoNavigate =\n source.includes('useEffect') &&\n source.includes(\"status === 'connected'\") &&\n source.includes(\"navigate\") &&\n source.includes(\"'home'\");\n expect(hasAutoNavigate, 'LoginScreen should auto-navigate to home when status becomes connected').to.be.true;\n});",
"lineNumber": 5
},
{
"pattern": "l'écran gère l'état de connexion en cours",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran gère l\\'état de connexion en cours', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const hasConnectingState =\n source.includes(\"status === 'connecting'\") ||\n source.includes(\"Connexion NextGraph en cours\");\n expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;\n});",
"lineNumber": 16
},
{
"pattern": "l'écran n'importe pas de données de démonstration",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran n\\'importe pas de données de démonstration', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const importsSeedData = source.includes('seedData') || source.includes('seedEvents');\n const usesFestipodData = source.includes('useFestipodData');\n expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;\n expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;\n});",
"lineNumber": 24
},
{
"pattern": "un événement {string} existe",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {\n const exists = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n return [...td.events].some((e: any) => e.title === title);\n },\n eventTitle,\n );\n expect(exists, `Event \"${eventTitle}\" should exist in the data layer`).to.be.true;\n});",
"lineNumber": 10
},
{
"pattern": "l'utilisateur n'est pas inscrit à l'événement {string}",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'utilisateur n\\'est pas inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 21
},
{
"pattern": "l'utilisateur est inscrit à l'événement {string}",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'utilisateur est inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 32
},
{
"pattern": "l'événement {string} a {int} participants au départ",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {\n await this.appFrame!.evaluate(\n ([title, c]: [string, number]) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.updateEvent(event['@id'], { participantCount: c });",
"lineNumber": 43
},
{
"pattern": "l'utilisateur s'inscrit à l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur s\\'inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 56
},
{
"pattern": "l'utilisateur se désinscrit de l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur se désinscrit de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 67
},
{
"pattern": "l'utilisateur essaie de s'inscrire une seconde fois à l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur essaie de s\\'inscrire une seconde fois à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 78
},
{
"pattern": "l'utilisateur est participant de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur est participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should be participating in \"${eventTitle}\"`).to.be.true;\n});",
"lineNumber": 91
},
{
"pattern": "l'utilisateur n'est plus participant de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur n\\'est plus participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should NOT be participating in \"${eventTitle}\"`).to.be.false;\n});",
"lineNumber": 104
},
{
"pattern": "l'événement {string} compte {int} participants",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n return event?.participantCount ?? -1;\n },\n eventTitle,\n );\n expect(count, `Event \"${eventTitle}\" participant count`).to.equal(expectedCount);\n});",
"lineNumber": 117
},
{
"pattern": "l'utilisateur apparaît dans la liste des participants de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur apparaît dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should appear in participants of \"${eventTitle}\"`).to.be.true;\n});",
"lineNumber": 129
},
{
"pattern": "l'utilisateur n'apparaît plus dans la liste des participants de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur n\\'apparaît plus dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should NOT appear in participants of \"${eventTitle}\"`).to.be.false;\n});",
"lineNumber": 142
},
{
"pattern": "l'inscription est idempotente pour l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'inscription est idempotente pour l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return 0;\n return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;\n },\n eventTitle,\n );\n expect(count, 'User should have exactly one participation record').to.equal(1);\n});",
"lineNumber": 155
},
{
"pattern": "je clique sur un événement",
"keyword": "When",
+139 -71
View File
@@ -1,6 +1,6 @@
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { execSync } from 'child_process';
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
import { execSync, spawn, type ChildProcess } from 'child_process';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
@@ -23,9 +23,81 @@ let harnessServer: http.Server | null = null;
let harnessPort = 0;
let useRealBroker = false;
// E2E: real app server
let appServerProcess: ChildProcess | null = null;
let appPort = 0;
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
/**
* Navigate through the NG broker to load an app in its iframe.
* Handles wallet login and returns the app's Frame.
*/
async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: 'domcontentloaded' });
// Automate wallet login if needed
const loginButton = page.getByText('Login', { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await page.waitForURL('**/wallet/login', { timeout: 5000 }).catch(() => {});
const walletLink = page.getByText('Click here to login with your wallet');
await walletLink.waitFor({ state: 'visible', timeout: 5000 });
await walletLink.click();
await page.waitForTimeout(1000);
const passwordInput = 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 page.waitForTimeout(3000);
}
// Verify iframe loaded after login
const iframeCount = await page.locator('iframe').count();
if (iframeCount === 0) {
const bodyText = await 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: Frame | null = null;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
for (const f of 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 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() ?? null;
if (appFrame) break;
}
}
if (appFrame) break;
await page.waitForTimeout(500);
}
if (!appFrame) {
const frames = page.frames().map(f => f.url());
throw new Error(`App iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
}
return appFrame;
}
/**
* Automated wallet creation + login on nextgraph.eu.
* Flow:
@@ -165,6 +237,35 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
],
});
console.log('[Hooks] Real broker mode ready');
// Start real app server for @e2e tests
appPort = await new Promise<number>((resolve) => {
const s = http.createServer();
s.listen(0, '127.0.0.1', () => {
const port = (s.address() as { port: number }).port;
s.close(() => resolve(port));
});
});
appServerProcess = spawn('bun', ['src/index.ts'], {
env: { ...process.env, PORT: String(appPort), NODE_ENV: 'production' },
stdio: 'pipe',
cwd: process.cwd(),
});
// Wait for app server to respond
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 15000;
const check = () => {
http.get(`http://127.0.0.1:${appPort}`, (res) => {
res.resume();
resolve();
}).on('error', () => {
if (Date.now() > deadline) reject(new Error('App server startup timeout'));
else setTimeout(check, 200);
});
};
check();
});
console.log(`[E2E] App server on http://127.0.0.1:${appPort}`);
} catch (err) {
console.warn(`[Hooks] NG harness build/auth failed, falling back to mock: ${err}`);
useRealBroker = false;
@@ -184,9 +285,11 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.screenSourceContent = '';
this.currentScreen = null;
// Launch Playwright page for @data scenarios
// Launch Playwright page for @data and @e2e scenarios
const tags = scenario.pickle.tags.map(t => t.name);
if (tags.includes('@data')) {
const needsPlaywright = tags.includes('@data') || tags.includes('@e2e');
if (needsPlaywright) {
this.page = await browserContext.newPage();
// Capture console for debugging
@@ -194,72 +297,12 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.page.on('console', (msg) => {
if (msg.type() === 'error') console.error('[Browser console]', msg.text());
});
}
if (tags.includes('@data')) {
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;
const harnessUrl = `http://127.0.0.1:${harnessPort}`;
this.appFrame = await setupBrokerPage(this.page!, harnessUrl);
// Wait for NG session + useShape + bridge
await this.appFrame.waitForFunction(
@@ -268,16 +311,37 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
);
} 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) });
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();
this.appFrame = this.page!.mainFrame();
await this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
{ timeout: 10000 },
);
}
}
if (tags.includes('@e2e')) {
if (!useRealBroker || !appPort) {
throw new Error('@e2e scenarios require real broker mode (NG harness + app server)');
}
const realAppUrl = `http://127.0.0.1:${appPort}`;
this.appFrame = await setupBrokerPage(this.page!, realAppUrl);
// Wait for React to render the app
await this.appFrame.waitForFunction(
() => {
const root = document.getElementById('root');
return root && root.innerHTML.length > 100;
},
{ timeout: 30000 },
);
// Wait for NG to connect and providers to stabilize
await this.appFrame.waitForTimeout(3000);
}
});
After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
@@ -308,5 +372,9 @@ AfterAll(async function () {
if (harnessServer) {
await new Promise<void>((resolve) => harnessServer!.close(() => resolve()));
}
if (appServerProcess) {
appServerProcess.kill();
appServerProcess = null;
}
console.log('Festipod BDD tests completed.');
});
+59 -103
View File
@@ -2,15 +2,15 @@
* 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.
* Uses the REAL app providers (NextGraphProvider + FestipodDataProvider)
* so tests face the same code paths as the app.
*
* 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 { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-org/orm/react';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
@@ -19,86 +19,47 @@ import {
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
// ============================================================================
// 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
// App — uses real providers (same tree as the real app)
// ============================================================================
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 />;
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</NextGraphProvider>
);
}
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); };
}, []);
// Wait for NG connection before exposing the test bridge
function HarnessRouter() {
const { status } = useNextGraph();
if (status === 'connected') {
return <ConnectedHarness />;
}
if (status === 'error') {
return <div id="harness-status">ERROR</div>;
}
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)
// ============================================================================
// Connected harness — exposes window.__testData through real providers
// ============================================================================
function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// Raw DeepSignalSets for direct manipulation in tests
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>;
@@ -106,32 +67,11 @@ function ShapeConsumer() {
const [bridgeReady, setBridgeReady] = useState(false);
useEffect(() => {
// Wait a tick for useShape to populate
// Small delay for useShape to populate
const timer = setTimeout(() => {
const graph = `did:ng:${ngSession.private_store_id}`;
const session = ngCtx.session!;
// 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)
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
@@ -141,12 +81,19 @@ function ShapeConsumer() {
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
currentUserId,
session: ngSession,
session,
// --- App-level view (through real providers, same as what screens see) ---
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
},
@@ -159,13 +106,15 @@ function ShapeConsumer() {
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
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}`,
'@graph': `did:ng:${session.private_store_id}`,
'@type': 'http://festipod.org/Participation',
'@id': '', // auto-assigned by NG
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
@@ -189,14 +138,21 @@ function ShapeConsumer() {
}
}
},
/** Load the app's default seed data into the wallet */
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size, 'participations:', participations.size);
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
'participations:', participations.size, '| ngStatus:', ngCtx.status,
'| appData.events:', appData.events.length);
setBridgeReady(true);
}, 500); // Small delay for useShape to populate
}, 500);
return () => clearTimeout(timer);
}, [events, users, participations]);
}, [events, users, participations, ngCtx, appData]);
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
}
+19 -9
View File
@@ -16,21 +16,22 @@ export let sessionPromise: Promise<NextGraphSession> = new Promise(
}
);
let initCalled = false;
let initPromise: Promise<void> | null = null;
export async function init() {
if (initCalled) return;
initCalled = true;
console.log('[NG session] init() called');
await initNgWeb(
/**
* Register the initNgWeb callback. Idempotent — returns the same promise on repeated calls.
* Does NOT trigger login by itself. Call login() separately to open the wallet login page.
*/
export function init(): Promise<void> {
if (initPromise) return initPromise;
console.log('[NG session] init() called — registering callback');
initPromise = initNgWeb(
async (event: any) => {
console.log('[NG session] initNgWeb callback received, event type:', event?.type);
console.log('[NG session] initNgWeb callback received, event:', JSON.stringify(Object.keys(event || {})));
session = event.session;
session!.ng ??= ng;
console.log('[NG session] Session established, private_store_id:', session!.private_store_id);
resolveSessionPromise(session!);
initNgSignals(ng, session!);
console.log('[NG session] ORM signals initialized');
},
@@ -40,6 +41,15 @@ export async function init() {
console.error('[NG session] init error:', error);
rejectSessionPromise(error);
});
return initPromise;
}
/**
* Trigger the wallet login page. Must call init() first.
*/
export async function login() {
console.log('[NG session] login() called — opening wallet login');
await ng.login();
}
export interface NextGraphSession {