E2E testing infrastructure, NextGraph connection fixes, and documentation
- Add @e2e test layer: real app in broker iframe via Playwright - Fix broker redirect: conditional auto-init only when inside iframe - Fix seed data flash: empty data during 'connecting' phase - Fix Gallery button in iframe: explicit navigate instead of history.back - Add auth e2e feature scenarios and step definitions - Update docs: bdd-testing, data-layer-testing, data-layer, AGENTS.md - Add decision record for conditional NG init approach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`@ng-org/web`'s `initNgWeb()` checks `window.self === window.top`. When the app runs standalone (not in an iframe), it redirects the entire page to `nextgraph.net/redir/` to trigger broker authentication. This caused the app to redirect on every load — even during development or when the user hadn't clicked "Se connecter".
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Always auto-init NG on mount
|
||||
**Arguments for:**
|
||||
- Simpler code — no branching logic
|
||||
|
||||
**Arguments against:**
|
||||
- Causes immediate redirect to broker when loaded standalone
|
||||
- Breaks development workflow
|
||||
- User sees broker login page instead of the app
|
||||
|
||||
### Option B: Conditional auto-init based on iframe detection
|
||||
**Arguments for:**
|
||||
- When in iframe, the broker has already authenticated — safe to auto-init
|
||||
- When standalone, user must explicitly click "Se connecter" to trigger the redirect
|
||||
- Preserves standalone demo/development experience
|
||||
- Matches `@ng-org/web`'s own detection logic
|
||||
|
||||
**Arguments against:**
|
||||
- Relies on `window.self !== window.top` heuristic (could theoretically be wrong if embedded in non-broker iframe)
|
||||
|
||||
## Decision
|
||||
|
||||
Option B. `NextGraphContext` checks `const isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` at module level. `useEffect` only auto-calls `initNg()` when `isInsideBroker` is true. The `connect()` callback remains available for explicit user-initiated connection.
|
||||
|
||||
Additionally, `FestipodDataContext` now renders empty data (not seed data) during the `connecting` phase to avoid flashing demo content before the wallet loads.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- App loads without redirecting — works standalone for development and demo
|
||||
- In broker iframe, connection is seamless and automatic
|
||||
- No seed data flash during wallet connection
|
||||
|
||||
**Negative:**
|
||||
- None significant
|
||||
|
||||
**Risks:**
|
||||
- If `@ng-org/web` changes its detection logic, our guard may diverge — keep them aligned
|
||||
@@ -15,9 +15,9 @@ Each module has step directories for three test layers:
|
||||
|
||||
```
|
||||
src/modules/event/steps/
|
||||
ui/ # UI/screen assertions (active)
|
||||
data/ # Data layer assertions
|
||||
e2e/ # Full integration (planned)
|
||||
ui/ # UI/screen assertions (source analysis)
|
||||
data/ # Data layer assertions (Playwright + broker)
|
||||
e2e/ # E2E assertions (Playwright + broker + real app UI)
|
||||
```
|
||||
|
||||
Shared steps (cross-domain) live in `src/shared/steps/ui/`.
|
||||
@@ -97,6 +97,15 @@ Run all: `bun run test:cucumber`
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker. See [data-layer-testing](./data-layer-testing.md) for full architecture.
|
||||
|
||||
## E2E Testing
|
||||
|
||||
`@e2e` scenarios test the real app running in the broker iframe. See [data-layer-testing](./data-layer-testing.md#e2e-layer) for architecture. Key differences from `@data`:
|
||||
|
||||
- Uses the **real app** (not a test harness) served on a local HTTP port
|
||||
- Interacts via Playwright locators and `evaluate()` on the app iframe
|
||||
- Tests actual UI behavior: navigation, redirects, button clicks, screen content
|
||||
- Requires real broker mode (fails with `Error` if broker unavailable)
|
||||
|
||||
## Adding New Steps
|
||||
|
||||
1. **Module-specific**: Create in `src/modules/{module}/steps/ui/`
|
||||
|
||||
@@ -87,15 +87,75 @@ Exposed by the harness, consumed by steps via `appFrame.evaluate()`:
|
||||
- `isParticipating(eventId, userId)`, `getEventParticipants(eventId)` — queries
|
||||
- `updateEvent(eventId, updates)` — field updates
|
||||
|
||||
## E2E Layer (`@e2e`)
|
||||
|
||||
`@e2e` scenarios test the real app UI running inside the broker iframe. Unlike `@data` which loads a test harness, `@e2e` loads the actual app.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
|
||||
↓
|
||||
Broker wallet login (automated, same as @data)
|
||||
↓
|
||||
Broker loads REAL APP in iframe → http://127.0.0.1:{appPort}
|
||||
↓
|
||||
App renders with NextGraphProvider auto-connecting
|
||||
↓
|
||||
Steps interact via appFrame.evaluate() and Playwright locators
|
||||
```
|
||||
|
||||
### App Server
|
||||
|
||||
Started in `BeforeAll` alongside the harness server:
|
||||
1. Find a free port
|
||||
2. `spawn('bun', ['src/index.ts'], { env: { PORT: appPort } })`
|
||||
3. Poll until the server responds to HTTP GET
|
||||
4. Killed in `AfterAll`
|
||||
|
||||
### Shared Infrastructure
|
||||
|
||||
`@e2e` reuses the same `setupBrokerPage()` helper as `@data` — handles broker redirect URL construction, wallet login automation, and iframe discovery.
|
||||
|
||||
### Step Definitions
|
||||
|
||||
E2E steps live in module directories (e.g., `src/modules/auth/steps/e2e/connexion.steps.ts`). They use:
|
||||
- `this.appFrame!.evaluate()` — run JS in the app iframe (hash navigation, content checks)
|
||||
- `this.appFrame!.locator()` — find and interact with DOM elements
|
||||
- `this.appFrame!.waitForFunction()` — poll for expected state (screen content, URL changes)
|
||||
- `SCREEN_MARKERS` — map screen IDs to unique text content for verification
|
||||
|
||||
### Before Hook (`@e2e`)
|
||||
|
||||
```
|
||||
1. Open new Playwright page
|
||||
2. setupBrokerPage(page, realAppUrl) → automated login → find app iframe
|
||||
3. Wait for React render (root.innerHTML.length > 100)
|
||||
4. Wait 3s for NG connection + provider stabilization
|
||||
```
|
||||
|
||||
### Differences from `@data`
|
||||
|
||||
| Aspect | `@data` | `@e2e` |
|
||||
|--------|---------|--------|
|
||||
| What loads in iframe | Test harness (`harness-ng.tsx`) | Real app (`src/index.ts`) |
|
||||
| Ready signal | `window.__testData.ready === true` | `root.innerHTML.length > 100` |
|
||||
| Interaction | `evaluate()` on test bridge | `evaluate()` + Playwright locators |
|
||||
| Mock fallback | Yes (standalone DeepSignalSets) | No — requires real broker |
|
||||
| Tests | Data operations (CRUD, queries) | UI behavior (navigation, redirects, clicks) |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/test-harness/harness-ng.tsx` | Real broker harness (useShape through broker iframe) |
|
||||
| `src/shared/test-harness/harness.tsx` | Mock harness (DeepSignalSets, no broker) |
|
||||
| `src/shared/support/hooks.ts` | Playwright lifecycle (wallet creation, login automation, iframe detection) |
|
||||
| `src/shared/support/hooks.ts` | Playwright lifecycle (wallet creation, login automation, iframe detection, app server) |
|
||||
| `src/shared/support/world.ts` | World with `page`/`appFrame` fields |
|
||||
| `src/modules/event/steps/data/inscription.steps.ts` | Inscription data steps |
|
||||
| `src/modules/auth/steps/e2e/connexion.steps.ts` | Auth/connection e2e steps |
|
||||
| `.playwright-profile/` | Persistent Chromium profile (gitignored) |
|
||||
| `scripts/debug-browser.ts` | Manual browser debug tool — launches headed Chromium to inspect broker interactions |
|
||||
| `.playwright-profile-debug/` | Chromium profile created by debug-browser.ts (gitignored) |
|
||||
@@ -104,7 +164,7 @@ Exposed by the harness, consumed by steps via `appFrame.evaluate()`:
|
||||
|
||||
```bash
|
||||
bun run test:data # Run @data scenarios (real broker if wallet exists, mock fallback)
|
||||
bun run test:cucumber # Run all scenarios (UI + data)
|
||||
bun run test:cucumber # Run all scenarios (UI + data + e2e)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -39,15 +39,24 @@ Regenerate with `bun run build:orm`.
|
||||
|
||||
### NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
- Connection lifecycle: `disconnected` → `connecting` → `connected` | `error`
|
||||
- Auto-initializes via `initNg()` from `src/shared/utils/ngSession.ts`
|
||||
- Provides session with store IDs (private, protected, public)
|
||||
- **Conditional auto-init**: Only auto-calls `initNg()` when running inside the broker iframe (`window.self !== window.top`). Outside the iframe, `initNgWeb()` would redirect the page to the broker — so connection waits for explicit `connect()` call.
|
||||
- `connect()`: Called by user clicking "Se connecter". When outside broker, triggers the redirect flow.
|
||||
|
||||
#### `@ng-org/web` redirect behavior
|
||||
`initNgWeb()` checks `window.self === window.top`. If the app is NOT in an iframe, it redirects to `nextgraph.net/redir/` with the current URL encoded as a return parameter. The broker then loads the app back in an iframe after auth. This means the app must NOT auto-init NG when loaded standalone.
|
||||
|
||||
### FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
- Wraps NextGraph shapes with `useShapeWithDefaults()` hook
|
||||
- CRUD: `createEvent()`, `updateEvent()`, `joinEvent()`, `leaveEvent()`, etc.
|
||||
- Exposes `useFestipodData()` hook consumed by all screens
|
||||
- `selectedEventId` state for cross-screen event navigation
|
||||
- Falls back to seed data when disconnected
|
||||
- `loadTestData()`: Calls `bootstrapWallet()` to seed test data into NG wallet — only triggered by explicit user action
|
||||
- **Provider states based on NG status**:
|
||||
- `disconnected` → `LocalDataProvider` with seed data (demo mode)
|
||||
- `connecting` → `LocalDataProvider` with **empty data** (avoids flashing seed data before wallet loads)
|
||||
- `connected` → `NgDataProvider` with real wallet data
|
||||
- `error` → `LocalDataProvider` with seed data (graceful fallback)
|
||||
|
||||
## Data Types
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ NextGraph (P2P/local-first) with SHEX shapes and ORM. See [data-layer](.project/
|
||||
|
||||
Multi-layer Cucumber/Gherkin in French. See [bdd-testing](.project/knowledge/bdd-testing.md).
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker with Playwright. See [data-layer-testing](.project/knowledge/data-layer-testing.md).
|
||||
`@data` scenarios test data operations through the real NextGraph broker. `@e2e` scenarios test the real app UI in the broker iframe. Both use Playwright. See [data-layer-testing](.project/knowledge/data-layer-testing.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -47,4 +47,4 @@ bun run build:orm # Regenerate ORM from SHEX shapes
|
||||
- [Data Layer](.project/knowledge/data-layer.md) — NextGraph, shapes, context, seed data
|
||||
- [BDD Testing](.project/knowledge/bdd-testing.md) — Cucumber setup, step layers, feature files
|
||||
- [Screens](.project/knowledge/screens.md) — screen inventory, registry, sketchy components
|
||||
- [Data-Layer Testing](.project/knowledge/data-layer-testing.md) — real broker testing, wallet setup, Playwright harness
|
||||
- [Data-Layer Testing](.project/knowledge/data-layer-testing.md) — real broker testing, wallet setup, Playwright harness, e2e layer
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10837
-64
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -14,7 +14,7 @@ function AppContent() {
|
||||
return (
|
||||
<DemoMode
|
||||
initialScreenId={route.screenId}
|
||||
onBack={goBack}
|
||||
onBack={() => navigate({ page: 'gallery' })}
|
||||
onNavigateToStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,8 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
setHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
setCurrentScreenId(screenId);
|
||||
// Keep URL in sync so refresh preserves the current screen
|
||||
window.history.replaceState(null, '', `#/demo/${screenId}`);
|
||||
};
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame, BrokerBanner } from '../../shared/components/sketchy';
|
||||
import { screenGroups, type Screen } from '../../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { useNextGraph } from '../../shared/context/NextGraphContext';
|
||||
import { useFestipodData } from '../../shared/context/FestipodDataContext';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
@@ -25,6 +27,11 @@ const DEFAULT_SCALE = 0.5;
|
||||
export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
const isMobile = useIsMobile();
|
||||
const { status, connect } = useNextGraph();
|
||||
const { loadTestData } = useFestipodData();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isConnected = status === 'connected';
|
||||
const isConnecting = status === 'connecting';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -83,6 +90,49 @@ export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
|
||||
Specs BDD
|
||||
</button>
|
||||
|
||||
{/* NextGraph: login or load test data */}
|
||||
{!isConnected && (
|
||||
<button
|
||||
onClick={connect}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
background: isConnecting ? 'var(--tool-text-muted)' : '#4CAF50',
|
||||
color: 'white',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: isConnecting ? 'wait' : 'pointer',
|
||||
opacity: isConnecting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
)}
|
||||
{isConnected && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try { await loadTestData(); } finally { setLoading(false); }
|
||||
}}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: loading ? 'var(--tool-text-muted)' : '#FF9800',
|
||||
color: 'white',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Chargement...' : 'Charger données de test'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Zoom control - hide on mobile */}
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
En tant qu'utilisateur
|
||||
Je peux me connecter à mon portefeuille NextGraph
|
||||
Et charger les données de test dans mon portefeuille
|
||||
Afin d'utiliser l'application avec mes propres données
|
||||
|
||||
# --- UI layer: écran de connexion ---
|
||||
|
||||
@ui
|
||||
Scénario: L'écran de connexion affiche le bouton NextGraph
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran contient un bouton "Se connecter avec NextGraph"
|
||||
|
||||
@ui
|
||||
Scénario: L'écran de connexion redirige automatiquement quand connecté
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère la redirection automatique après connexion
|
||||
|
||||
@ui
|
||||
Scénario: L'état initial est "en cours" quand une connexion est en attente
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère l'état de connexion en cours
|
||||
|
||||
@ui
|
||||
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran n'importe pas de données de démonstration
|
||||
|
||||
# --- Data layer: comportement du portefeuille ---
|
||||
|
||||
@data
|
||||
Scénario: Un portefeuille connecté est vide par défaut
|
||||
Alors le portefeuille est connecté
|
||||
Et le portefeuille ne contient aucun événement de démonstration
|
||||
|
||||
@data
|
||||
Scénario: Charger les données de test dans le portefeuille
|
||||
Étant donné que le portefeuille est vide
|
||||
Quand je charge les données de test
|
||||
Alors le portefeuille contient des événements
|
||||
Et le portefeuille contient des utilisateurs
|
||||
|
||||
@data
|
||||
Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données
|
||||
Étant donné que le portefeuille contient déjà des événements
|
||||
Quand je charge les données de test
|
||||
Alors le nombre d'événements n'a pas changé
|
||||
|
||||
@data
|
||||
Scénario: Les données du portefeuille sont distinctes des données par défaut
|
||||
Étant donné que le portefeuille est vide
|
||||
Quand je charge les données de test
|
||||
Alors les événements ont des identifiants NextGraph
|
||||
Et les utilisateurs ont des identifiants NextGraph
|
||||
|
||||
# --- E2E layer: comportement réel dans le navigateur ---
|
||||
|
||||
@e2e
|
||||
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
|
||||
Quand l'utilisateur navigue vers l'écran "login"
|
||||
Alors l'application affiche l'écran "home"
|
||||
|
||||
@e2e
|
||||
Scénario: La navigation interne met à jour l'URL
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Alors l'URL contient "demo/events"
|
||||
|
||||
@e2e
|
||||
Scénario: L'application ne redirige pas vers le broker quand elle est dans l'iframe
|
||||
Alors l'application est toujours dans l'iframe
|
||||
|
||||
@e2e
|
||||
Scénario: Le bouton Galerie ramène à la galerie depuis le mode démo
|
||||
Quand l'utilisateur navigue vers l'écran "home" sans historique
|
||||
Et l'utilisateur clique sur le bouton "Galerie"
|
||||
Alors l'application affiche la galerie
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
import type { ScreenProps } from '../../../screens';
|
||||
|
||||
export function LoginScreen({ navigate }: ScreenProps) {
|
||||
const { status, connect } = useNextGraph();
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
// Auto-navigate to home when connection completes
|
||||
useEffect(() => {
|
||||
if (status === 'connected') {
|
||||
navigateRef.current('home');
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleNgLogin = () => {
|
||||
if (status === 'connected') {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
});
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
// If empty, seed some data first so the precondition holds
|
||||
if (count === 0) {
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
// Store count before loading for the idempotency test
|
||||
const countBefore = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
Then('le portefeuille est connecté', async function (this: FestipodWorld) {
|
||||
const hasSession = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.session !== undefined && td.session !== null;
|
||||
});
|
||||
expect(hasSession, 'Wallet should have an active NG session').to.be.true;
|
||||
});
|
||||
|
||||
Then('le portefeuille ne contient aucun événement de démonstration', async function (this: FestipodWorld) {
|
||||
// Wallet should be empty — no auto-seeding.
|
||||
// Also verify through the app's data context (same view as screens).
|
||||
const result = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return {
|
||||
walletEvents: td.events.size,
|
||||
appEvents: td.appData?.events?.length ?? -1,
|
||||
ngStatus: td.ngStatus,
|
||||
};
|
||||
});
|
||||
expect(result.walletEvents, 'Wallet should have no events').to.equal(0);
|
||||
// App-level view should also show no events (providers working correctly)
|
||||
expect(result.appEvents, 'App data context should show no events').to.equal(0);
|
||||
expect(result.ngStatus, 'NG status should be connected').to.equal('connected');
|
||||
});
|
||||
|
||||
Then('le portefeuille contient des événements', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
expect(count, 'Wallet should contain events').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('le portefeuille contient des utilisateurs', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.users.size;
|
||||
});
|
||||
expect(count, 'Wallet should contain users').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('le nombre d\'événements n\'a pas changé', async function (this: FestipodWorld) {
|
||||
const countBefore = (this as any)._eventCountBefore as number;
|
||||
const countAfter = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
expect(countAfter, 'Event count should not change after reload').to.equal(countBefore);
|
||||
});
|
||||
|
||||
Then('les événements ont des identifiants NextGraph', async function (this: FestipodWorld) {
|
||||
const ids = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.events].map((e: any) => e['@id']);
|
||||
});
|
||||
expect(ids.length, 'Should have events').to.be.greaterThan(0);
|
||||
for (const id of ids) {
|
||||
expect(id, `Event ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
|
||||
}
|
||||
});
|
||||
|
||||
Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {
|
||||
const ids = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.users].map((u: any) => u['@id']);
|
||||
});
|
||||
expect(ids.length, 'Should have users').to.be.greaterThan(0);
|
||||
for (const id of ids) {
|
||||
expect(id, `User ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// --- E2E step definitions ---
|
||||
// These interact with the REAL app running in the browser (via broker iframe),
|
||||
// not the test harness bridge. They test actual UI behavior.
|
||||
|
||||
// Screen content markers — text that uniquely identifies each screen
|
||||
const SCREEN_MARKERS: Record<string, string> = {
|
||||
'home': 'Mes événements à venir',
|
||||
'events': 'Découvrir',
|
||||
'login': 'connecter',
|
||||
'profile': 'Mon profil',
|
||||
'create-event': "Nom de l'événement",
|
||||
'settings': 'Paramètres',
|
||||
};
|
||||
|
||||
When('l\'utilisateur navigue vers l\'écran {string}', async function (this: FestipodWorld, screenId: string) {
|
||||
await this.appFrame!.evaluate((id: string) => {
|
||||
window.location.hash = `#/demo/${id}`;
|
||||
}, screenId);
|
||||
// Wait for React to process the navigation
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
When('l\'utilisateur navigue vers l\'écran {string} sans historique', async function (this: FestipodWorld, screenId: string) {
|
||||
// Use replaceState to navigate without creating a back-history entry
|
||||
// (simulates the app being loaded directly at a DemoMode URL in the broker iframe)
|
||||
await this.appFrame!.evaluate((id: string) => {
|
||||
window.history.replaceState(null, '', `#/demo/${id}`);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}, screenId);
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
Then('l\'application est toujours dans l\'iframe', async function (this: FestipodWorld) {
|
||||
// Verify the app didn't redirect away (initNgWeb would redirect if not in iframe)
|
||||
const url = await this.appFrame!.evaluate(() => window.location.href);
|
||||
expect(url, 'App should still be on localhost, not redirected to broker').to.include('127.0.0.1');
|
||||
|
||||
// Verify the app rendered (not a blank page or error)
|
||||
const hasContent = await this.appFrame!.evaluate(() => {
|
||||
const root = document.getElementById('root');
|
||||
return root && root.innerHTML.length > 100;
|
||||
});
|
||||
expect(hasContent, 'App should have rendered content').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'application affiche l\'écran {string}', async function (this: FestipodWorld, expectedScreenId: string) {
|
||||
const marker = SCREEN_MARKERS[expectedScreenId];
|
||||
|
||||
// Wait for the expected screen to appear (handles async redirects like login→home)
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
([id, markerText]: [string, string | undefined]) => {
|
||||
const hash = window.location.hash;
|
||||
if (!hash.includes(`demo/${id}`)) return false;
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root || root.innerHTML.length < 100) return false;
|
||||
|
||||
// If we have a marker, verify screen content too
|
||||
if (markerText) {
|
||||
return root.textContent?.includes(markerText) ?? false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[expectedScreenId, marker] as [string, string | undefined],
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
// Gather debug info on failure
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
hash: window.location.hash,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected screen "${expectedScreenId}" but got hash="${debug.hash}", ` +
|
||||
`content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('l\'URL contient {string}', async function (this: FestipodWorld, expected: string) {
|
||||
const hash = await this.appFrame!.evaluate(() => window.location.hash);
|
||||
expect(hash, `URL hash should contain "${expected}"`).to.include(expected);
|
||||
});
|
||||
|
||||
When('l\'utilisateur clique sur le bouton {string}', async function (this: FestipodWorld, buttonText: string) {
|
||||
// Click button matching the text inside the app iframe
|
||||
const button = this.appFrame!.locator(`button`, { hasText: buttonText });
|
||||
await button.first().click();
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
Then('l\'application affiche la galerie', async function (this: FestipodWorld) {
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return false;
|
||||
const hash = window.location.hash;
|
||||
// Gallery is at #/ or empty hash
|
||||
const isGalleryHash = hash === '#/' || hash === '' || hash === '#';
|
||||
// Gallery shows screen thumbnails — look for "Tous les écrans" or screen grid
|
||||
const isGalleryContent = root.textContent?.includes('Wireframe') ?? false;
|
||||
return isGalleryHash || isGalleryContent;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
hash: window.location.hash,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected Gallery but got hash="${debug.hash}", content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Then('l\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
// LoginScreen must have a useEffect that navigates on status === 'connected'
|
||||
const hasAutoNavigate =
|
||||
source.includes('useEffect') &&
|
||||
source.includes("status === 'connected'") &&
|
||||
source.includes("navigate") &&
|
||||
source.includes("'home'");
|
||||
expect(hasAutoNavigate, 'LoginScreen should auto-navigate to home when status becomes connected').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran gère l\'état de connexion en cours', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const hasConnectingState =
|
||||
source.includes("status === 'connecting'") ||
|
||||
source.includes("Connexion NextGraph en cours");
|
||||
expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran n\'importe pas de données de démonstration', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const importsSeedData = source.includes('seedData') || source.includes('seedEvents');
|
||||
const usesFestipodData = source.includes('useFestipodData');
|
||||
expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;
|
||||
expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;
|
||||
});
|
||||
@@ -8,14 +8,20 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const exists = await this.appFrame!.evaluate(
|
||||
// Ensure wallet has data (seed if empty)
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
if (td.events.size === 0) td.loadTestData();
|
||||
});
|
||||
// Wait for event to appear
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.events].some((e: any) => e.title === title);
|
||||
},
|
||||
eventTitle,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(exists, `Event "${eventTitle}" should exist in the data layer`).to.be.true;
|
||||
});
|
||||
|
||||
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user