E2E testing infrastructure, NextGraph connection fixes, and documentation

- Add @e2e test layer: real app in broker iframe via Playwright
- Fix broker redirect: conditional auto-init only when inside iframe
- Fix seed data flash: empty data during 'connecting' phase
- Fix Gallery button in iframe: explicit navigate instead of history.back
- Add auth e2e feature scenarios and step definitions
- Update docs: bdd-testing, data-layer-testing, data-layer, AGENTS.md
- Add decision record for conditional NG init approach

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-03-13 17:51:44 +01:00
parent 6f9b3ece34
commit 708cbeead8
23 changed files with 12033 additions and 361 deletions
+150
View File
@@ -615,6 +615,156 @@ export const parsedFeatures: ParsedFeature[] = [
"meeting-points"
]
},
{
"id": "connexion-nextgraph",
"name": "Connexion NextGraph et chargement des données",
"description": "En tant qu'utilisateur Je peux me connecter à mon portefeuille NextGraph Et charger les données de test dans mon portefeuille Afin d'utiliser l'application avec mes propres données",
"tags": [
"@AUTH",
"@priority-1"
],
"category": "UNKNOWN",
"priority": 1,
"scenarios": [
{
"name": "L'écran de connexion affiche le bouton NextGraph",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran contient un bouton \"Se connecter avec NextGraph\""
}
]
},
{
"name": "L'écran de connexion redirige automatiquement quand connecté",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran gère la redirection automatique après connexion"
}
]
},
{
"name": "L'état initial est \"en cours\" quand une connexion est en attente",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran gère l'état de connexion en cours"
}
]
},
{
"name": "Aucune donnée de démonstration n'est visible pendant la connexion",
"tags": [],
"steps": [
{
"keyword": "Étant donné",
"text": "je suis sur la page \"connexion\""
},
{
"keyword": "Alors",
"text": "l'écran n'importe pas de données de démonstration"
}
]
},
{
"name": "Un portefeuille connecté est vide par défaut",
"tags": [],
"steps": [
{
"keyword": "Alors",
"text": "le portefeuille est connecté"
},
{
"keyword": "Et",
"text": "le portefeuille ne contient aucun événement de démonstration"
}
]
},
{
"name": "Charger les données de test dans le portefeuille",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille est vide"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "le portefeuille contient des événements"
},
{
"keyword": "Et",
"text": "le portefeuille contient des utilisateurs"
}
]
},
{
"name": "Les données de test ne sont pas rechargées si le portefeuille contient déjà des données",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille contient déjà des événements"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "le nombre d'événements n'a pas changé"
}
]
},
{
"name": "Les données du portefeuille sont distinctes des données par défaut",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "le portefeuille est vide"
},
{
"keyword": "Quand",
"text": "je charge les données de test"
},
{
"keyword": "Alors",
"text": "les événements ont des identifiants NextGraph"
},
{
"keyword": "Et",
"text": "les utilisateurs ont des identifiants NextGraph"
}
]
}
],
"filePath": "src/modules/auth/features/connexion-nextgraph.feature",
"rawContent": "# language: fr\n@AUTH @priority-1\nFonctionnalité: Connexion NextGraph et chargement des données\n En tant qu'utilisateur\n Je peux me connecter à mon portefeuille NextGraph\n Et charger les données de test dans mon portefeuille\n Afin d'utiliser l'application avec mes propres données\n\n # --- UI layer: écran de connexion ---\n\n @ui\n Scénario: L'écran de connexion affiche le bouton NextGraph\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran contient un bouton \"Se connecter avec NextGraph\"\n\n @ui\n Scénario: L'écran de connexion redirige automatiquement quand connecté\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère la redirection automatique après connexion\n\n @ui\n Scénario: L'état initial est \"en cours\" quand une connexion est en attente\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère l'état de connexion en cours\n\n @ui\n Scénario: Aucune donnée de démonstration n'est visible pendant la connexion\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran n'importe pas de données de démonstration\n\n # --- Data layer: comportement du portefeuille ---\n\n @data\n Scénario: Un portefeuille connecté est vide par défaut\n Alors le portefeuille est connecté\n Et le portefeuille ne contient aucun événement de démonstration\n\n @data\n Scénario: Charger les données de test dans le portefeuille\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors le portefeuille contient des événements\n Et le portefeuille contient des utilisateurs\n\n @data\n Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données\n Étant donné que le portefeuille contient déjà des événements\n Quand je charge les données de test\n Alors le nombre d'événements n'a pas changé\n\n @data\n Scénario: Les données du portefeuille sont distinctes des données par défaut\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors les événements ont des identifiants NextGraph\n Et les utilisateurs ont des identifiants NextGraph\n",
"screenIds": [
"login"
]
},
{
"id": "us-23",
"name": "US-23 Me connecter avec d'autres utilisateurs",
+182
View File
@@ -10,6 +10,188 @@ export interface StepDefinitionInfo {
}
export const stepDefinitions: StepDefinitionInfo[] = [
{
"pattern": "le portefeuille est vide",
"keyword": "Given",
"file": "connexion.steps.ts",
"sourceCode": "Given('le portefeuille est vide', async function (this: FestipodWorld) {\n // Verify starting state: the harness graph should have its own seeded data.\n // We clear events/users/participations to simulate a truly empty wallet.\n await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n // Delete all events\n for (const e of [...td.events]) td.events.delete(e);\n // Delete all users\n for (const u of [...td.users]) td.users.delete(u);\n // Delete all participations\n for (const p of [...td.participations]) td.participations.delete(p);\n });",
"lineNumber": 10
},
{
"pattern": "le portefeuille contient déjà des événements",
"keyword": "Given",
"file": "connexion.steps.ts",
"sourceCode": "Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 32
},
{
"pattern": "je charge les données de test",
"keyword": "When",
"file": "connexion.steps.ts",
"sourceCode": "When('je charge les données de test', async function (this: FestipodWorld) {\n // Store count before loading for the idempotency test\n const countBefore = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 53
},
{
"pattern": "le portefeuille est connecté",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille est connecté', async function (this: FestipodWorld) {\n const hasSession = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.session !== undefined && td.session !== null;\n });",
"lineNumber": 81
},
{
"pattern": "le portefeuille ne contient aucun événement de démonstration",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille ne contient aucun événement de démonstration', async function (this: FestipodWorld) {\n // Wallet should be empty — no auto-seeding.\n // Also verify through the app's data context (same view as screens).\n const result = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return {\n walletEvents: td.events.size,\n appEvents: td.appData?.events?.length ?? -1,\n ngStatus: td.ngStatus,\n };\n });",
"lineNumber": 89
},
{
"pattern": "le portefeuille contient des événements",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille contient des événements', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 106
},
{
"pattern": "le portefeuille contient des utilisateurs",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le portefeuille contient des utilisateurs', async function (this: FestipodWorld) {\n const count = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.users.size;\n });",
"lineNumber": 114
},
{
"pattern": "le nombre d'événements n'a pas changé",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('le nombre d\\'événements n\\'a pas changé', async function (this: FestipodWorld) {\n const countBefore = (this as any)._eventCountBefore as number;\n const countAfter = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return td.events.size;\n });",
"lineNumber": 122
},
{
"pattern": "les événements ont des identifiants NextGraph",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('les événements ont des identifiants NextGraph', async function (this: FestipodWorld) {\n const ids = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return [...td.events].map((e: any) => e['@id']);\n });",
"lineNumber": 131
},
{
"pattern": "les utilisateurs ont des identifiants NextGraph",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {\n const ids = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return [...td.users].map((u: any) => u['@id']);\n });",
"lineNumber": 142
},
{
"pattern": "l'écran gère la redirection automatique après connexion",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n // LoginScreen must have a useEffect that navigates on status === 'connected'\n const hasAutoNavigate =\n source.includes('useEffect') &&\n source.includes(\"status === 'connected'\") &&\n source.includes(\"navigate\") &&\n source.includes(\"'home'\");\n expect(hasAutoNavigate, 'LoginScreen should auto-navigate to home when status becomes connected').to.be.true;\n});",
"lineNumber": 5
},
{
"pattern": "l'écran gère l'état de connexion en cours",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran gère l\\'état de connexion en cours', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const hasConnectingState =\n source.includes(\"status === 'connecting'\") ||\n source.includes(\"Connexion NextGraph en cours\");\n expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;\n});",
"lineNumber": 16
},
{
"pattern": "l'écran n'importe pas de données de démonstration",
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran n\\'importe pas de données de démonstration', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const importsSeedData = source.includes('seedData') || source.includes('seedEvents');\n const usesFestipodData = source.includes('useFestipodData');\n expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;\n expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;\n});",
"lineNumber": 24
},
{
"pattern": "un événement {string} existe",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {\n const exists = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n return [...td.events].some((e: any) => e.title === title);\n },\n eventTitle,\n );\n expect(exists, `Event \"${eventTitle}\" should exist in the data layer`).to.be.true;\n});",
"lineNumber": 10
},
{
"pattern": "l'utilisateur n'est pas inscrit à l'événement {string}",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'utilisateur n\\'est pas inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 21
},
{
"pattern": "l'utilisateur est inscrit à l'événement {string}",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'utilisateur est inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 32
},
{
"pattern": "l'événement {string} a {int} participants au départ",
"keyword": "Given",
"file": "inscription.steps.ts",
"sourceCode": "Given('l\\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {\n await this.appFrame!.evaluate(\n ([title, c]: [string, number]) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.updateEvent(event['@id'], { participantCount: c });",
"lineNumber": 43
},
{
"pattern": "l'utilisateur s'inscrit à l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur s\\'inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 56
},
{
"pattern": "l'utilisateur se désinscrit de l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur se désinscrit de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 67
},
{
"pattern": "l'utilisateur essaie de s'inscrire une seconde fois à l'événement {string}",
"keyword": "When",
"file": "inscription.steps.ts",
"sourceCode": "When('l\\'utilisateur essaie de s\\'inscrire une seconde fois à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
"lineNumber": 78
},
{
"pattern": "l'utilisateur est participant de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur est participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should be participating in \"${eventTitle}\"`).to.be.true;\n});",
"lineNumber": 91
},
{
"pattern": "l'utilisateur n'est plus participant de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur n\\'est plus participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should NOT be participating in \"${eventTitle}\"`).to.be.false;\n});",
"lineNumber": 104
},
{
"pattern": "l'événement {string} compte {int} participants",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n return event?.participantCount ?? -1;\n },\n eventTitle,\n );\n expect(count, `Event \"${eventTitle}\" participant count`).to.equal(expectedCount);\n});",
"lineNumber": 117
},
{
"pattern": "l'utilisateur apparaît dans la liste des participants de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur apparaît dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should appear in participants of \"${eventTitle}\"`).to.be.true;\n});",
"lineNumber": 129
},
{
"pattern": "l'utilisateur n'apparaît plus dans la liste des participants de l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'utilisateur n\\'apparaît plus dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should NOT appear in participants of \"${eventTitle}\"`).to.be.false;\n});",
"lineNumber": 142
},
{
"pattern": "l'inscription est idempotente pour l'événement {string}",
"keyword": "Then",
"file": "inscription.steps.ts",
"sourceCode": "Then('l\\'inscription est idempotente pour l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return 0;\n return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;\n },\n eventTitle,\n );\n expect(count, 'User should have exactly one participation record').to.equal(1);\n});",
"lineNumber": 155
},
{
"pattern": "je clique sur un événement",
"keyword": "When",