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:
@@ -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;">✓ 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,
|
||||
[],
|
||||
);
|
||||
@@ -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 />);
|
||||
@@ -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 />);
|
||||
Reference in New Issue
Block a user