feat(auth): staging wallet partagé — import assisté par fichier + e2e multi-navigateur

Stopgap staging multi-user sur wallet partagé (cf. brief_2026-06-15_shared-wallet-shim).

Distribution / import du wallet :
- AccessGateScreen : barrière d'accès ON PAR DÉFAUT (désactivable via
  globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ pour tests/dev). Fournit le FICHIER
  .ngw + le mot de passe + un guide en 3 étapes (import assisté sur nextgraph.eu —
  le broker hébergé n'autorise pas l'import inline pendant l'auth web-app).
- sharedWallet.ts + build.ts : fichier copié en /shared-wallet.ngw, mot de passe gravé.
- Ancien LoginScreen (/login) retiré ; atterrissage post-login -> /home.
- NextGraphContext : dé-piégeage de l'état "connecting" au retour (pageshow/bfcache).

Couche multistore stopgap : storeRegistry, isolation, AccountContext, FestipodDataContext.

Tests e2e multi-navigateur :
- browserPool + world.openBrowser : contextes frais isolés, 2 axes orthogonaux
  (nb de navigateurs × modèle de wallet own/shared).
- @humain : parcours humain complet (télécharge -> importe le fichier sur
  nextgraph.eu -> Entrer -> pseudo -> accueil).
- Bypass de la barrière pour @e2e via context.addInitScript.
- Convention @wip exclue via cucumber.json.

Docs (concepts) : nextgraph-platform (knowledge_broker-import-constraint,
decision_2026-06-17_assisted-wallet-import), bdd-testing (knowledge_multibrowser-harness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-06-30 12:04:02 +02:00
parent 222658a75d
commit 266e33556d
40 changed files with 2224 additions and 360 deletions
@@ -6,29 +6,10 @@ Fonctionnalité: Connexion NextGraph et chargement des données
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 @wip
# Behavioral: requires simulating an NG status change. Better tested at the
# @e2e layer where a real connected session triggers the redirect.
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
# NB : l'ancien écran /login (LoginScreen) a été retiré — l'accès NextGraph
# passe désormais par l'AccessGateScreen (barrière ON par défaut), cf.
# decision_2026-06-17_assisted-wallet-import. Les scénarios @ui qui testaient
# le LoginScreen ont été supprimés en conséquence.
# --- Data layer: comportement du portefeuille ---
@@ -59,11 +40,6 @@ Fonctionnalité: Connexion NextGraph et chargement des données
# --- 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"
@@ -0,0 +1,137 @@
/**
* AccessGateScreen — the *technical access barrier* of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
* REAL NextGraph login, shown before the app renders. Because it precedes the
* app, the user reads it as "access to the test environment", not as an app
* login. Clicking "Entrer" triggers `connect()`, which redirects to the broker
* to open the SHARED wallet. After return (inside the broker iframe) NG
* auto-connects and the app shows the perceived login (ConnexionScreen).
*
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
* web-app auth: a first-time device has no wallet, so the broker redirect would
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
*/
import { useState, type ReactNode } from 'react';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
interface AccessGateScreenProps {
status: 'disconnected' | 'connecting' | 'connected' | 'error';
error?: string;
onEnter: () => void;
}
// One numbered step: a badge + a title + the action for that step.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
<div style={{
flexShrink: 0, width: 26, height: 26, borderRadius: '50%', background: '#E8590C',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14,
}}>{n}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ margin: '2px 0 8px', fontWeight: 600, fontSize: 14 }}>{title}</Text>
{children}
</div>
</div>
);
}
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
const connecting = status === 'connecting';
const [copied, setCopied] = useState(false);
const copyPassword = async () => {
try {
await navigator.clipboard.writeText(SHARED_WALLET_PASSWORD);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard may be blocked — the password stays selectable
}
};
const entrer = (
<Button
variant="primary"
onClick={onEnter}
disabled={connecting}
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
>
{connecting ? 'Accès en cours…' : 'Entrer'}
</Button>
);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
{hasSharedWallet() ? (
<>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
</Text>
<Step n={1} title="Téléchargez le portefeuille">
<a
data-testid="shared-wallet-download"
href={SHARED_WALLET_FILE_URL}
download="festipod-wallet.ngw"
style={{
display: 'block', textAlign: 'center', textDecoration: 'none',
padding: 10, borderRadius: 10, background: '#E8590C', color: '#fff', fontWeight: 600, fontSize: 14,
}}
>
⬇ Télécharger le portefeuille
</a>
</Step>
<Step n={2} title="Importez-le sur NextGraph">
<Text style={{ margin: '0 0 8px', fontSize: 13, lineHeight: 1.6, color: '#666' }}>
<a href={WALLET_IMPORT_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#E8590C', fontWeight: 600 }}>
Ouvrir la page d'import
</a>{' '}(nouvel onglet) → « Import a Wallet File » → choisissez le fichier → mot de passe :
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<code
data-testid="shared-wallet-password"
style={{ flex: 1, padding: '6px 10px', background: '#fff', border: '1px solid #eee', borderRadius: 8, fontSize: 13, userSelect: 'all' }}
>
{SHARED_WALLET_PASSWORD}
</code>
<Button variant="accent-outline" onClick={copyPassword} style={{ padding: '6px 10px', fontSize: 12 }}>
{copied ? 'Copié ✓' : 'Copier'}
</Button>
</div>
</Step>
<Step n={3} title="Revenez sur cet onglet et entrez">
{entrer}
</Step>
</>
) : (
entrer
)}
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: '#c92a2a', marginTop: 12 }}>
{error || "Accès à l'environnement impossible. Réessayez."}
</Text>
)}
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta
</Text>
</div>
);
}
@@ -0,0 +1,92 @@
/**
* ConnexionScreen — the *perceived* login of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
* login (AccessGateScreen) already happened and is not perceived as a login;
* THIS screen is what the user experiences as "logging in": they pick a
* username (no password — declarative). The username is persisted by
* AccountContext (localStorage) and resolved against the accounts living in
* the shared wallet.
*/
import { useState } from 'react';
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
import { useAccount } from '../../../shared/context/AccountContext';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
export function ConnexionScreen() {
const { login } = useAccount();
const { users } = useFestipodData();
const navigate = useNavigate();
const [value, setValue] = useState('');
// Choosing a username completes the login → land on the app (not the '/'
// welcome/onboarding screen, which the access gate has replaced upstream).
const doLogin = (name: string) => {
login(name);
navigate('/home');
};
const submit = () => {
if (value.trim()) doLogin(value);
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
Choisissez votre nom d'utilisateur
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
<Input
placeholder="@votrepseudo"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit();
}}
/>
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
Se connecter
</Button>
</div>
{users.length > 0 && (
<>
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
ou reprenez un compte existant
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{users.map(u => (
<button
key={u.id}
onClick={() => doLogin(u.username)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 10,
border: '1px solid #eee',
borderRadius: 12,
background: '#fff',
cursor: 'pointer',
textAlign: 'left',
}}
>
<Avatar initials={u.initials} color="#E8590C" size="sm" />
<span>
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
);
}
@@ -1,14 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { LoginScreen } from './LoginScreen';
import { withProviders } from '../../../../.storybook/decorators';
const meta: Meta<typeof LoginScreen> = {
title: 'Screens/Auth/LoginScreen',
component: LoginScreen,
decorators: [withProviders],
};
export default meta;
type Story = StoryObj<typeof LoginScreen>;
export const Default: Story = {};
-95
View File
@@ -1,95 +0,0 @@
import { useEffect } from 'react';
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
import { useNavigate } from '../../../app/router';
export function LoginScreen() {
const navigate = useNavigate();
const { status, connect } = useNextGraph();
useEffect(() => {
if (status === 'connected') {
navigate('/home');
}
}, [status]);
const handleNgLogin = () => {
if (status === 'connected') {
navigate('/home');
} else {
connect();
}
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>Créez et rejoignez des événements entre amis</Text>
{/* NextGraph login */}
<div style={{ marginBottom: 24 }}>
{status === 'connected' ? (
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<Text style={{ color: '#22543D', fontWeight: 'bold', margin: '0 0 8px 0' }}>
✓ Connecté via NextGraph
</Text>
<Button variant="primary" onClick={() => navigate('/home')} style={{ width: '100%' }}>
Continuer vers l'accueil
</Button>
</div>
) : status === 'connecting' ? (
<Button disabled style={{ width: '100%', opacity: 0.6 }}>
Connexion NextGraph en cours...
</Button>
) : (
<div>
<Button
variant="primary"
onClick={handleNgLogin}
style={{ width: '100%' }}
>
Se connecter avec NextGraph
</Button>
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: '#888', marginTop: 8 }}>
NextGraph non disponible — mode démonstration
</Text>
)}
</div>
)}
</div>
<Divider />
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888', margin: '16px 0' }}>
ou connexion classique (démo)
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Email</Text>
<Input type="email" placeholder="vous@exemple.com" />
</div>
<div>
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Mot de passe</Text>
<Input type="password" placeholder="••••••••" />
</div>
<Button variant="primary" onClick={() => navigate('/home')}>
Se connecter
</Button>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#E8590C' }}>
Mot de passe oublié ?
</Text>
</div>
</div>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888' }}>
Pas encore de compte ? <span style={{ color: '#E8590C', cursor: 'pointer' }}>S'inscrire</span>
</Text>
</div>
);
}
+12 -2
View File
@@ -1,8 +1,18 @@
import { useEffect } from 'react';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import { useNavigate } from '../../../app/router';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
export function WelcomeScreen() {
const navigate = useNavigate();
const { status } = useNextGraph();
// Onboarding is for NOT-connected users. A connected user landing on '/'
// (e.g. a returning tester past the access gate) goes straight to the app.
useEffect(() => {
if (status === 'connected') navigate('/home');
}, [status]);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
@@ -41,12 +51,12 @@ export function WelcomeScreen() {
</div>
</div>
<Button variant="primary" onClick={() => navigate('/login')} style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={() => navigate('/home')} style={{ marginBottom: 12 }}>
Rejoindre la communauté
</Button>
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888' }}>
Déjà membre ? <span onClick={() => navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
</Text>
</div>
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared wallet material for the staging stopgap.
*
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
* import on nextgraph.eu.
*
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
* TextCode is a transient device-to-device transfer (5 min, source device
* online, single use) — useless to embed. A wallet file is STATIC and reusable.
* So Festipod serves the file (download) + shows the shared password; the user
* imports it via nextgraph.eu → "Import a Wallet File".
*
* ZERO-SECURITY shared credential (friendly users) → embedding the file +
* password is consistent with the posture.
*
* `build.ts` copies the file (from FESTIPOD_SHARED_WALLET_FILE) to the bundle as
* `/shared-wallet.ngw`, and `define`s the password global from
* FESTIPOD_SHARED_WALLET_PASSWORD. Empty password → no shared wallet configured
* → the gate falls back to the plain flow.
*/
// Build-injected global (not `process.env`, absent in the browser); any path
// that doesn't inject it reads `undefined` → '' safely (no ReferenceError).
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_SHARED_WALLET_PASSWORD__: string | undefined;
}
export const SHARED_WALLET_PASSWORD: string = globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ ?? '';
/** URL of the shared wallet file in the bundle (copied by build.ts). */
export const SHARED_WALLET_FILE_URL = '/shared-wallet.ngw';
/** Standalone NextGraph wallet app — where the import actually happens. */
export const WALLET_IMPORT_URL = 'https://nextgraph.eu/#/wallet/login';
/** Whether Festipod has a shared wallet to hand over (drives the assisted UI). */
export const hasSharedWallet = (): boolean => SHARED_WALLET_PASSWORD.trim().length > 0;
@@ -13,7 +13,6 @@ import type { FestipodWorld } from '../../../../shared/support/world';
const SCREEN_MARKERS: Record<string, string> = {
'home': 'Festipod',
'events': 'Découvrir',
'login': 'connecter',
'profile': 'Mon profil',
'create-event': "Relayer un événement",
'settings': 'Paramètres',
@@ -35,7 +34,6 @@ function pathForScreen(screenId: string): string {
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends-list': return '/profile/friends';
@@ -1,27 +0,0 @@
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) {
// Behavioral — covered by the @e2e scenario
// "L'écran de connexion redirige vers l'accueil si déjà connecté".
// At the @ui layer we only verify the screen mounts cleanly.
expect(this.currentScreenId).to.equal('login');
expect(this.renderedDoc, 'Login screen should render').to.not.be.null;
});
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;
});
@@ -74,12 +74,12 @@ export function CreateEventScreen() {
setStep((step - 1) as Step);
};
const submit = () => {
const submit = async () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: 'Date à définir';
const newEvent = createEvent({
const newEvent = await createEvent({
title: name || 'Nouvel événement',
date: dateLabel,
startDate,
+26 -1
View File
@@ -2,13 +2,31 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
import { useNavigate } from '../../../app/router';
import { useAccount } from '../../../shared/context/AccountContext';
import { logoutNg } from '../../../shared/utils/ngSession';
export function SettingsScreen() {
const navigate = useNavigate();
const { logout } = useAccount();
const [notifications, setNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [location, setLocation] = useState(true);
// Faux logout: clears the perceived login (username) only — the shared
// wallet stays open underneath. In staging this returns to ConnexionScreen.
const handleLogout = () => {
logout();
navigate('/');
};
// Real logout (HIDDEN): stops the shared wallet session — forces a broker
// redirect on next access. Stopgap-only escape hatch.
const handleLeaveEnvironment = async () => {
await logoutNg();
logout();
if (typeof window !== 'undefined') window.location.reload();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -74,9 +92,16 @@ export function SettingsScreen() {
<Divider />
<ListItem onClick={() => navigate('/login')}>
<ListItem onClick={handleLogout}>
<Text style={{ margin: 0, color: '#E53E3E' }}>Se déconnecter</Text>
</ListItem>
{/* Stopgap escape hatch — real wallet logout, kept discreet. */}
<ListItem onClick={handleLeaveEnvironment}>
<Text style={{ margin: 0, fontSize: 12, color: '#bbb' }}>
Quitter l'environnement de test
</Text>
</ListItem>
</div>
<BottomNav active="profile" />
@@ -0,0 +1,68 @@
# language: fr
@data @multibrowser
Fonctionnalité: Harness multi-navigateur — modèles private-wallet et shared-wallet
Pour comparer sereinement les deux modèles de wallet (chacun le sien vs partagé)
En tant que développeur du stopgap puis de la cible NextGraph
Le harness e2e doit piloter plusieurs navigateurs isolés dans un seul scénario,
sous l'un OU l'autre modèle de wallet — deux axes orthogonaux.
# --- Axe machinerie : isolation des contextes (modèle private-wallet) ---
@private-wallet
Scénario: Deux navigateurs avec leur propre wallet ont des stockages locaux indépendants
Étant donné un navigateur "A" avec son propre wallet
Et un navigateur "B" avec son propre wallet
Quand j'écris "valeur-A" sous la clé "sonde" dans le navigateur "A"
Alors la clé "sonde" vaut "valeur-A" dans le navigateur "A"
Et la clé "sonde" est absente dans le navigateur "B"
# Le wallet NextGraph vit sur l'origine du broker (nextgraph.net). Ce scénario
# prouve l'isolation du stockage LÀ, pas seulement sur l'origine locale.
@private-wallet
Scénario: Sur l'origine du broker, deux navigateurs private-wallet restent isolés
Étant donné un navigateur "A" avec son propre wallet
Et un navigateur "B" avec son propre wallet
Quand le navigateur "A" charge l'origine du broker
Et le navigateur "B" charge l'origine du broker
Et j'écris "faux-wallet" sous la clé "ng_probe" dans le navigateur "A"
Alors la clé "ng_probe" vaut "faux-wallet" dans le navigateur "A"
Et la clé "ng_probe" est absente dans le navigateur "B"
# --- Axe wallet : provisioning shared-wallet (injection storageState) ---
# Deux navigateurs distincts portent LE MÊME wallet partagé (injecté au niveau
# harness). Tous deux atteignent l'app connectée à NextGraph sans login manuel.
@shared-wallet
Scénario: Deux navigateurs partageant le wallet se connectent tous deux à NextGraph
Étant donné un navigateur "A" avec le wallet partagé
Et un navigateur "B" avec le wallet partagé
Quand le navigateur "A" charge l'application via le broker
Et le navigateur "B" charge l'application via le broker
Alors le navigateur "A" est connecté à NextGraph
Et le navigateur "B" est connecté à NextGraph
# --- Distribution produit : import ASSISTÉ (pas d'auto-import zéro-touche) ---
#
# L'auto-import zéro-touche par l'app est PROUVÉ IMPOSSIBLE avec le broker
# hébergé : il n'implémente pas l'import inline pendant l'auth web-app et
# renvoie vers nextgraph.eu (cross-origin, non pilotable par Festipod). Voir
# concept nextgraph-platform → knowledge_broker-import-constraint et
# decision_2026-06-17_assisted-wallet-import.
#
# PARCOURS HUMAIN COMPLET — exerce la VRAIE app (staging, gate ON) de bout en
# bout : Festipod propose le FICHIER du portefeuille → l'humain le télécharge et
# l'importe sur nextgraph.eu (« Import a Wallet File » + mot de passe) → revient
# → « Entrer » → connecté. Le FICHIER est la primitive correcte (statique,
# réutilisable) — le TextCode est un transfert temporaire 5 min, inutilisable à
# embarquer (cf. nextgraph-platform → knowledge_broker-import-constraint).
# (≠ scénario @shared-wallet ci-dessus, qui INJECTE le wallet via storageState
# et court-circuite donc l'import — provisioning de TEST, pas le flux produit.)
@shared-wallet @assisted-import @humain
Scénario: Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte
Étant donné un nouveau testeur ouvre Festipod en staging sur un navigateur vierge
Alors Festipod affiche l'écran d'accès avec le portefeuille à télécharger
Quand le testeur télécharge le portefeuille et l'importe sur nextgraph.eu
Et le testeur revient sur Festipod et clique « Entrer »
Alors Festipod est connecté et propose de choisir un nom d'utilisateur
Quand le testeur choisit un nom d'utilisateur
Alors il arrive sur l'accueil de l'application
@@ -0,0 +1,28 @@
# language: fr
@WORKSHOP @priority-1
Fonctionnalité: Stopgap multi-store — primitives de données
En tant que développeur
Je veux valider, contre le vrai broker NextGraph, les primitives du stopgap
wallet partagé (création de documents, ORM sur un document créé, aller-retour
du sharedWalletShim) avant d'activer le mode multi-document.
# --- Data (broker réel) ---
@data
Scénario: L'ORM lit et écrit dans un document créé par doc_create
Étant donné un nouveau document de graphe est créé dans le wallet partagé
Quand j'écris une participation dans ce document via l'ORM
Alors la participation est lisible dans ce document
@data
Scénario: Le sharedWalletShim fait l'aller-retour par le wallet
Étant donné un compte "@smoketest" est enregistré dans le shim
Alors le compte "@smoketest" est retrouvé après rechargement du shim
Et le compte "@smoketest" possède trois documents de périmètre distincts
@data
Scénario: Lecture fan-out sur plusieurs documents d'entité (1 doc par entité)
Étant donné deux comptes ayant chacun un document d'événement indexé
Quand j'écris un événement dans chacun de ces deux documents
Alors un abonnement multi-graphes lit les deux événements ensemble
Et l'index public liste les deux documents
@@ -0,0 +1,147 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import { pool } from '../../../../shared/support/browserPool';
// Multi-browser harness steps. Two ORTHOGONAL axes:
// - browser count : several named, isolated browsers in one scenario;
// - wallet model : 'own' (its own/no wallet) vs 'shared' (THE shared wallet).
// The wallet model is carried explicitly by the Given phrasing.
// See brief_2026-06-15_shared-wallet-shim.
Given('un navigateur {string} avec son propre wallet', async function (this: FestipodWorld, name: string) {
const handle = await this.openBrowser(name, 'own');
// Land on the local harness origin (no NG stack) so localStorage is available.
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
});
Given('un navigateur {string} avec le wallet partagé', async function (this: FestipodWorld, name: string) {
const handle = await this.openBrowser(name, 'shared');
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
});
// --- Parcours HUMAIN complet (e2e fidèle) ---
// Ouvre la VRAIE app en staging (gate ON), lit le code À L'ÉCRAN, l'importe sur
// nextgraph.eu, revient, clique « Entrer » → app connectée. C'est la garantie
// que Festipod remet à l'humain un code qui marche. Browser fixe "H".
Given('un nouveau testeur ouvre Festipod en staging sur un navigateur vierge', async function (this: FestipodWorld) {
const url = await pool.ensureStagingApp();
(this as any).stagingUrl = url;
const handle = await this.openBrowser('H', 'own'); // vierge, AUCUN wallet
await handle.page.goto(url, { waitUntil: 'domcontentloaded' });
// L'écran d'accès (AccessGateScreen) doit s'afficher.
await handle.page.getByText('Entrer', { exact: true }).waitFor({ state: 'visible', timeout: 15000 });
});
Then('Festipod affiche l\'écran d\'accès avec le portefeuille à télécharger', async function (this: FestipodWorld) {
const page = this.browser('H').page;
// Le fichier du portefeuille est proposé au téléchargement…
const downloadVisible = await page.locator('[data-testid=shared-wallet-download]').isVisible();
expect(downloadVisible, 'le bouton de téléchargement du portefeuille doit être affiché').to.equal(true);
// …et le mot de passe partagé est affiché (c'est bien CELUI du wallet e2e).
const pwd = (await page.locator('[data-testid=shared-wallet-password]').innerText()).trim();
expect(pwd, 'le mot de passe affiché doit être celui du wallet partagé').to.equal(pool.sharedWalletPassword);
(this as any).displayedPassword = pwd;
});
When('le testeur télécharge le portefeuille et l\'importe sur nextgraph.eu', async function (this: FestipodWorld) {
const page = this.browser('H').page;
// Télécharge le fichier DEPUIS l'écran Festipod (le vrai geste humain)…
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[data-testid=shared-wallet-download]').click(),
]);
const filePath = await download.path();
// …et l'importe via "Import a Wallet File" avec le mot de passe lu à l'écran.
await pool.importWalletViaFile(page, filePath!, (this as any).displayedPassword);
});
When('le testeur revient sur Festipod et clique « Entrer »', async function (this: FestipodWorld) {
const handle = this.browser('H');
await handle.page.goto((this as any).stagingUrl, { waitUntil: 'domcontentloaded' });
const entrer = handle.page.getByText('Entrer', { exact: true });
await entrer.waitFor({ state: 'visible', timeout: 15000 });
await entrer.click(); // déclenche le redirect vers le broker
await handle.page.waitForURL('**nextgraph**', { timeout: 20000 }).catch(() => {});
// Le broker demande de déverrouiller le wallet fraîchement importé → son mot de
// passe (completeBrokerLogin attend la page de login wallet de façon robuste).
handle.appFrame = await pool.completeBrokerLogin(handle.page, (this as any).stagingUrl, pool.sharedWalletPassword);
});
Then('Festipod est connecté et propose de choisir un nom d\'utilisateur', async function (this: FestipodWorld) {
const handle = this.browser('H');
expect(handle.appFrame, 'l\'app doit être chargée dans l\'iframe broker').to.not.equal(null);
// Connecté → AuthGate passe l'AccessGateScreen et montre ConnexionScreen.
await handle.appFrame!.waitForFunction(
() => /Choisissez votre nom d'utilisateur|Connexion/.test(document.body?.innerText ?? ''),
{ timeout: 30000 },
);
});
When('le testeur choisit un nom d\'utilisateur', async function (this: FestipodWorld) {
const frame = this.browser('H').appFrame!;
await frame.locator('input[placeholder="@votrepseudo"]').fill('@testeur');
await frame.getByRole('button', { name: 'Se connecter' }).click();
});
Then('il arrive sur l\'accueil de l\'application', async function (this: FestipodWorld) {
const frame = this.browser('H').appFrame!;
// On atterrit sur /home (l'app), PAS sur l'onboarding hors-connexion ('/'
// WelcomeScreen « Rejoindre la communauté »).
await frame.waitForFunction(
() => window.location.pathname.endsWith('/home') &&
!(document.body?.innerText ?? '').includes('Rejoindre la communauté'),
{ timeout: 15000 },
);
});
When('le navigateur {string} charge l\'application via le broker', async function (this: FestipodWorld, name: string) {
// Drive the named browser through the broker into the NG harness iframe.
// A 'shared' browser carries the wallet (storageState) → the broker recognises
// it; an 'own' browser has none → the broker cannot reach the app.
await this.loadAppInBrowser(name, 'harness');
});
Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {
const handle = this.browser(name);
expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);
// __testData.ready flips true only once the NG session is connected.
await handle.appFrame!.waitForFunction(
() => (window as any).__testData?.ready === true,
{ timeout: 30000 },
);
});
When('le navigateur {string} charge l\'origine du broker', async function (this: FestipodWorld, name: string) {
// Navigate top-level to the broker origin (nextgraph.net) — the exact origin
// where the NG wallet localStorage lives. Each fresh context has its own
// storage partition there too.
await this.browser(name).page.goto(pool.brokerOrigin, { waitUntil: 'domcontentloaded' });
});
When(
'j\'écris {string} sous la clé {string} dans le navigateur {string}',
async function (this: FestipodWorld, value: string, key: string, name: string) {
await this.browser(name).page.evaluate(
([k, v]) => localStorage.setItem(k, v),
[key, value] as [string, string],
);
},
);
Then(
'la clé {string} vaut {string} dans le navigateur {string}',
async function (this: FestipodWorld, key: string, expected: string, name: string) {
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
expect(actual, `localStorage["${key}"] dans le navigateur ${name}`).to.equal(expected);
},
);
Then(
'la clé {string} est absente dans le navigateur {string}',
async function (this: FestipodWorld, key: string, name: string) {
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
expect(actual, `localStorage["${key}"] dans le navigateur ${name} doit être isolé`).to.equal(null);
},
);
@@ -0,0 +1,109 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Data-layer validation of the shared-wallet multi-store stopgap, against the
// REAL broker. Exercises the exact app mechanisms: doc_create, a real
// useShape({graphs}) on the created doc (window.__smoke, via <SmokeProbe>), and
// the sharedWalletShim round-trip (window.__testData.validateShim).
// See brief_2026-06-15_shared-wallet-shim.
// --- Scenario 1: ORM on a doc_create'd document ---
Given('un nouveau document de graphe est créé dans le wallet partagé', async function (this: FestipodWorld) {
const nuri = await this.appFrame!.evaluate(async () => {
return await (window as any).__testData.createSmokeDoc();
});
expect(nuri, 'doc_create should return a NURI').to.be.a('string');
expect((nuri as string).length, 'doc_create NURI should be non-empty').to.be.greaterThan(0);
// Wait for <SmokeProbe> to mount the useShape({graphs}) and expose __smoke.
await this.appFrame!.waitForFunction(
() => (window as any).__smoke?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris une participation dans ce document via l\'ORM', async function (this: FestipodWorld) {
await this.appFrame!.evaluate(() => (window as any).__smoke.add());
});
Then('la participation est lisible dans ce document', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__smoke.count() >= 1,
null,
{ timeout: 15000 },
);
const items = await this.appFrame!.evaluate(() => (window as any).__smoke.items());
expect(items.length, 'participation should be readable via ORM on the created doc').to.be.greaterThan(0);
});
// --- Scenario 2: sharedWalletShim round-trip ---
Given('un compte {string} est enregistré dans le shim', async function (this: FestipodWorld, username: string) {
const res = await this.appFrame!.evaluate(
async (u) => await (window as any).__testData.validateShim(u),
username,
);
(this as any).shimResult = res;
expect(res?.created, 'ensureAccount should return a record').to.exist;
});
Then('le compte {string} est retrouvé après rechargement du shim', function (this: FestipodWorld, username: string) {
const res = (this as any).shimResult;
expect(res?.reloaded, `account ${username} should reload from the wallet shim`).to.exist;
expect(res.reloaded.username).to.equal(username);
});
Then('le compte {string} possède trois documents de périmètre distincts', function (this: FestipodWorld, _username: string) {
const r = (this as any).shimResult?.reloaded;
expect(r, 'reloaded account should exist').to.exist;
const docs = [r.docPublic, r.docProtected, r.docPrivate];
for (const d of docs) {
expect(d, 'each scope doc should be a string').to.be.a('string');
expect((d as string).length, 'each scope doc NURI should be non-empty').to.be.greaterThan(0);
}
expect(new Set(docs).size, 'the three scope docs must be distinct').to.equal(3);
});
// --- Scenario 3: per-entity granularity + multi-graph fan-out ---
Given('deux comptes ayant chacun un document d\'événement indexé', async function (this: FestipodWorld) {
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.setupFanout());
(this as any).fanout = res;
expect(res.docA, 'event doc A').to.be.a('string');
expect(res.docB, 'event doc B').to.be.a('string');
await this.appFrame!.waitForFunction(
() => (window as any).__fanout?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris un événement dans chacun de ces deux documents', async function (this: FestipodWorld) {
const { docA, docB } = (this as any).fanout;
await this.appFrame!.evaluate(
([a, b]: [string, string]) => {
(window as any).__fanout.addEventTo(a, 'FanA');
(window as any).__fanout.addEventTo(b, 'FanB');
},
[docA, docB] as [string, string],
);
});
Then('un abonnement multi-graphes lit les deux événements ensemble', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__fanout.count() >= 2,
null,
{ timeout: 15000 },
);
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
expect(titles, 'fan-out should read event from doc A').to.include('FanA');
expect(titles, 'fan-out should read event from doc B').to.include('FanB');
});
Then('l\'index public liste les deux documents', function (this: FestipodWorld) {
const { docA, docB, listed } = (this as any).fanout;
expect(listed, 'public index should list doc A').to.include(docA);
expect(listed, 'public index should list doc B').to.include(docB);
});