diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md index 8a46cc7..fa6baba 100644 --- a/.project/concepts/app-architecture/_debt.md +++ b/.project/concepts/app-architecture/_debt.md @@ -9,3 +9,4 @@ - TOUCHED src/modules/user/screens/UpdateProfileScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) - TOUCHED src/modules/event/screens/CreateEventScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) - TOUCHED src/modules/event/screens/MeetingPointsScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/screens/UpdateEventScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/app-security/_debt.md b/.project/concepts/app-security/_debt.md new file mode 100644 index 0000000..29e6fb0 --- /dev/null +++ b/.project/concepts/app-security/_debt.md @@ -0,0 +1,7 @@ +# Doc-debt — app-security + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED src/modules/auth/steps/e2e/connexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/bdd-testing/_debt.md b/.project/concepts/bdd-testing/_debt.md index d1c19a8..5ee5a8a 100644 --- a/.project/concepts/bdd-testing/_debt.md +++ b/.project/concepts/bdd-testing/_debt.md @@ -6,3 +6,11 @@ ## Raw markers (consolidate into blocks, then delete) - TOUCHED src/shared/test-harness/harness-ng.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) - TOUCHED src/shared/test-harness/harness.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/shared/support/hooks.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/workshop/steps/data/multibrowser.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/steps/e2e/evenement.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/auth/steps/e2e/connexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/src/modules/auth/steps/e2e/connexion.steps.ts b/src/modules/auth/steps/e2e/connexion.steps.ts index d02e7e3..19d46d6 100644 --- a/src/modules/auth/steps/e2e/connexion.steps.ts +++ b/src/modules/auth/steps/e2e/connexion.steps.ts @@ -136,6 +136,7 @@ When('l\'utilisateur attend la fin du chargement', async function (this: Festipo const buttons = Array.from(document.querySelectorAll('button')); return !buttons.some(b => b.textContent?.includes('Chargement...')); }, + undefined, { timeout: 60000 }, ); await this.appFrame!.waitForTimeout(2000); @@ -152,6 +153,7 @@ Then('l\'écran d\'accueil affiche des événements', async function (this: Fest const appeared = await this.appFrame!.waitForFunction( () => document.querySelectorAll('.app-card').length > 0, + undefined, { timeout: 15000 }, ).then(() => true).catch(() => false); diff --git a/src/modules/event/screens/UpdateEventScreen.tsx b/src/modules/event/screens/UpdateEventScreen.tsx index f5a6821..a7a1d8d 100644 --- a/src/modules/event/screens/UpdateEventScreen.tsx +++ b/src/modules/event/screens/UpdateEventScreen.tsx @@ -6,8 +6,16 @@ import { useNavigate, useParams } from '../../../app/router'; export function UpdateEventScreen() { const navigate = useNavigate(); const { eventId } = useParams(); - const { getEvent, updateEvent } = useFestipodData(); + const { getEvent, updateEvent, getEventOwnership } = useFestipodData(); const event = eventId ? getEvent(eventId) : undefined; + // THE ROUTE IS GUARDED BY THE SAME ANSWER EventDetailScreen uses for its pencil + // icon — "editing is owning", so there is nothing else to ask. `'unknown'` is a + // real third case (the owned-document listing may not have landed yet), and it + // is rendered as its OWN pending state below rather than folded into either + // side: showing the form would let a non-owner edit on a still-resolving + // guess, and bouncing the user out would tell an actual owner, wrongly, that + // the event is not theirs. + const ownership = eventId ? getEventOwnership(eventId) : 'unknown'; const [title, setTitle] = useState(event?.title ?? ''); const [startDate, setStartDate] = useState(event?.startDate ?? ''); @@ -22,7 +30,11 @@ export function UpdateEventScreen() { const dateLabel = startDate ? (endDate ? `${startDate} - ${endDate}` : startDate) : event?.date ?? ''; - updateEvent(eventId, { + // THE CONFIRMATION FOLLOWS THE WRITE — same idiom as EventDetailScreen's + // participation toggle. Showing the toast and navigating away before + // `updateEvent` has settled announced success whether or not anything was + // actually written; a rejection must be told as a failure, not swallowed. + void Promise.resolve(updateEvent(eventId, { title, date: dateLabel, startDate, @@ -31,11 +43,54 @@ export function UpdateEventScreen() { endTime, location, description, - }); - showToast('Événement mis à jour', 'success'); - navigate(`/events/${eventId}`); + })) + .then(() => { + showToast('Événement mis à jour', 'success'); + navigate(`/events/${eventId}`); + }) + .catch((err: unknown) => { + console.error('[UpdateEvent] event update failed:', err); + showToast("La modification n'a pas pu être enregistrée", 'error'); + }); }; + if (ownership === 'not-mine') { + // A resolved, definitive answer — not a guess. Block the form outright + // rather than let a non-owner type into a write that will only ever reject. + return ( +
+
navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕} + /> +
+ + Vous ne pouvez pas modifier cet événement. + +
+
+ ); + } + + if (ownership === 'unknown') { + // The listing hasn't landed yet — neither "mine" nor "not mine" is true, so + // neither the form nor a bounce-out is shown. Same wording as + // EventDetailScreen's pending pencil affordance. + return ( +
+
navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕} + /> +
+ + Vérification de vos droits de modification… + +
+
+ ); + } + return (
(window as any).__testData?.ready === true, + undefined, { timeout: 60000 }, ); // Resolve A's principal (profile read hydrated) before the reactive Then reads. diff --git a/src/modules/event/steps/data/reconnexion.steps.ts b/src/modules/event/steps/data/reconnexion.steps.ts index f029947..5616c73 100644 --- a/src/modules/event/steps/data/reconnexion.steps.ts +++ b/src/modules/event/steps/data/reconnexion.steps.ts @@ -82,7 +82,7 @@ Then('l\'événement {string} finit par apparaître sur la page fraîche A en la console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`); try { freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!); - await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 }); + await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 }); await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); }); (this as any).recoFreshFrame = freshFrame; } catch (e) { @@ -113,7 +113,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet', freshPage.on('console', (msg) => { console.log(`[FreshApage:${msg.type()}]`, msg.text()); }); // New broker login → fresh verifier session on the SAME persistent wallet. const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!); - await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 }); + await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 }); // Let A's listing effect + anchored union read run on the fresh session (this is // exactly the cold-start read path the fix heals). await freshFrame.evaluate(async () => { diff --git a/src/modules/event/steps/e2e/evenement.steps.ts b/src/modules/event/steps/e2e/evenement.steps.ts index f55d719..2595043 100644 --- a/src/modules/event/steps/e2e/evenement.steps.ts +++ b/src/modules/event/steps/e2e/evenement.steps.ts @@ -20,6 +20,7 @@ Given('le portefeuille contient des données de test', async function (this: Fes // EventsScreen renders Card components (class app-card) when events load. const hasData = await this.appFrame!.waitForFunction( () => document.querySelectorAll('.app-card').length > 0, + undefined, { timeout: 30000 }, ).then(() => true).catch(() => false); @@ -69,6 +70,7 @@ When('l\'utilisateur remplit le formulaire de création d\'événement:', async const formReady = await this.appFrame!.waitForFunction( () => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'), + undefined, { timeout: 10000 }, ).then(() => true).catch(() => false); @@ -131,6 +133,7 @@ When('l\'utilisateur modifie le champ lieu avec {string}', async function (this: // adjacent to that label. await this.appFrame!.waitForFunction( () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false, + undefined, { timeout: 10000 }, ); await this.appFrame!.evaluate((val: string) => { @@ -164,6 +167,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t }); const homeHasCards = await this.appFrame!.waitForFunction( () => document.querySelectorAll('.app-card').length > 0, + undefined, { timeout: 5000 }, ).then(() => true).catch(() => false); if (!homeHasCards) { @@ -173,6 +177,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t }); await this.appFrame!.waitForFunction( () => document.querySelectorAll('.app-card').length > 0, + undefined, { timeout: 10000 }, ); } @@ -211,6 +216,7 @@ When('l\'utilisateur clique sur un événement de la liste', async function (thi // EventsScreen also uses Card with .app-card class. await this.appFrame!.waitForFunction( () => document.querySelectorAll('.app-card').length > 0, + undefined, { timeout: 10000 }, ); const clicked = await this.appFrame!.evaluate(() => { diff --git a/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts b/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts index d70c827..87c910c 100644 --- a/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts +++ b/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @@ -93,6 +93,7 @@ Given('l\'utilisateur crée un événement {string} via le vrai formulaire', { t }); const formReady = await frame.waitForFunction( () => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'), + undefined, { timeout: 15000 }, ).then(() => true).catch(() => false); if (!formReady) { @@ -189,6 +190,7 @@ When('l\'utilisateur ferme et rouvre l\'app sous la même identité dans une ses const root = document.getElementById('root'); return !!root && root.innerHTML.length > 100; }, + undefined, { timeout: 60000 }, ); // Let NG connect + the cold-start read path run. diff --git a/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts b/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts index 90fa44b..723dbd8 100644 --- a/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts +++ b/src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts @@ -42,6 +42,7 @@ Then("l'accueil rend un contenu d'application réel", async function (this: Fest document.querySelector('[aria-label="Relayer un événement"]') !== null; return hasNavbar && hasRelayer; }, + undefined, { timeout: 15000 }, ).then(() => true).catch(() => false); diff --git a/src/modules/workshop/steps/data/multibrowser.steps.ts b/src/modules/workshop/steps/data/multibrowser.steps.ts index bce72c8..0402e31 100644 --- a/src/modules/workshop/steps/data/multibrowser.steps.ts +++ b/src/modules/workshop/steps/data/multibrowser.steps.ts @@ -27,6 +27,7 @@ Then('le navigateur {string} est connecté à NextGraph', async function (this: // __testData.ready flips true only once the NG session is connected. await handle.appFrame!.waitForFunction( () => (window as any).__testData?.ready === true, + undefined, { timeout: 30000 }, ); }); diff --git a/src/shared/data/stepDefinitions.ts b/src/shared/data/stepDefinitions.ts index 740d93c..82ff46e 100644 --- a/src/shared/data/stepDefinitions.ts +++ b/src/shared/data/stepDefinitions.ts @@ -126,7 +126,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "pattern": "l'utilisateur attend la fin du chargement", "keyword": "When", "file": "connexion.steps.ts", - "sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});", + "sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n undefined,\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});", "lineNumber": 133 }, { @@ -134,7 +134,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "keyword": "Then", "file": "connexion.steps.ts", "sourceCode": "Then('l\\'écran d\\'accueil affiche des événements', async function (this: FestipodWorld) {\n // Navigate to the events screen (path-based) and verify cards are rendered.\n // Home shows only events the current user participates in, which depends\n // on participations hydrating from NG — flaky for a basic data check.\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/events');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });", - "lineNumber": 144 + "lineNumber": 145 }, { "pattern": "le créateur relaie l'événement {string}", @@ -399,7 +399,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "pattern": "l'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu'à 60 secondes à la barrière avec rechargements", "keyword": "Then", "file": "reconnexion.steps.ts", - "sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });", + "sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });", "lineNumber": 56 }, { @@ -442,70 +442,70 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "keyword": "When", "file": "evenement.steps.ts", "sourceCode": "When('l\\'utilisateur attend que l\\'écran {string} soit affiché', async function (this: FestipodWorld, screenId: string) {\n // We match on pathname prefix to allow for dynamic ids (event-detail etc.).\n const expectedPath = screenId === 'event-detail' ? '/events/' :\n screenId === 'update-event' ? '/edit' :\n screenId === 'create-event' ? '/events/new' :\n screenId === 'home' ? '/home' :\n screenId === 'events' ? '/events' :\n '/' + screenId;\n\n await this.appFrame!.waitForFunction(\n (path: string) => {\n const current = window.location.pathname;\n if (path === '/edit') return current.endsWith('/edit');\n return current.startsWith(path);\n },\n expectedPath,\n { timeout: 10000 },\n );\n await this.appFrame!.waitForTimeout(1000);\n});", - "lineNumber": 37 + "lineNumber": 38 }, { "pattern": "l'utilisateur remplit le formulaire de création d'événement:", "keyword": "When", "file": "evenement.steps.ts", - "sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });", - "lineNumber": 60 + "sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n undefined,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });", + "lineNumber": 61 }, { "pattern": "l'utilisateur modifie le champ lieu avec {string}", "keyword": "When", "file": "evenement.steps.ts", - "sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});", - "lineNumber": 129 + "sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n undefined,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});", + "lineNumber": 131 }, { "pattern": "l'utilisateur clique sur un événement de l'accueil", "keyword": "When", "file": "evenement.steps.ts", "sourceCode": "When('l\\'utilisateur clique sur un événement de l\\'accueil', async function (this: FestipodWorld) {\n // HomeScreen renders only events the current user participates in. If\n // participations haven't hydrated from NG yet, the screen is empty — fall\n // back to /events (no participation filter).\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });", - "lineNumber": 157 + "lineNumber": 160 }, { "pattern": "l'utilisateur clique sur un événement de la liste", "keyword": "When", "file": "evenement.steps.ts", - "sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });", - "lineNumber": 210 + "sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n undefined,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });", + "lineNumber": 215 }, { "pattern": "l'utilisateur clique sur le bouton de modification", "keyword": "When", "file": "evenement.steps.ts", "sourceCode": "When('l\\'utilisateur clique sur le bouton de modification', async function (this: FestipodWorld) {\n // The edit button shows \"✎\" in the header — only visible if user is event owner\n const editBtn = this.appFrame!.locator('text=✎').first();\n await editBtn.click();\n await this.appFrame!.waitForTimeout(1500);\n});", - "lineNumber": 233 + "lineNumber": 239 }, { "pattern": "l'utilisateur clique sur le bouton {string} si visible", "keyword": "When", "file": "evenement.steps.ts", "sourceCode": "When('l\\'utilisateur clique sur le bouton {string} si visible', async function (this: FestipodWorld, buttonText: string) {\n const button = this.appFrame!.locator('button', { hasText: buttonText }).first();\n if (await button.isVisible({ timeout: 3000 }).catch(() => false)) {\n await button.click();\n await this.appFrame!.waitForTimeout(1000);\n }\n});", - "lineNumber": 240 + "lineNumber": 246 }, { "pattern": "l'écran contient le texte {string}", "keyword": "Then", "file": "evenement.steps.ts", "sourceCode": "Then('l\\'écran contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n const appeared = await this.appFrame!.waitForFunction(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n expectedText,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(\n `Expected text \"${expectedText}\" not found. Path: \"${debug.pathname}\", content: \"${debug.rootText}\"`,\n );\n }\n});", - "lineNumber": 250 + "lineNumber": 256 }, { "pattern": "l'écran ne contient pas le texte {string}", "keyword": "Then", "file": "evenement.steps.ts", "sourceCode": "Then('l\\'écran ne contient pas le texte {string}', async function (this: FestipodWorld, unexpectedText: string) {\n await this.appFrame!.waitForTimeout(500);\n const found = await this.appFrame!.evaluate(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n unexpectedText,\n );\n expect(found, `Text \"${unexpectedText}\" should NOT be present`).to.be.false;\n});", - "lineNumber": 268 + "lineNumber": 274 }, { "pattern": "l'écran d'accueil contient le texte {string}", "keyword": "Then", "file": "evenement.steps.ts", "sourceCode": "Then('l\\'écran d\\'accueil contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });", - "lineNumber": 277 + "lineNumber": 283 }, { "pattern": "le navigateur {string} crée l'événement {string}", @@ -624,21 +624,21 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "keyword": "Given", "file": "reconnexion-persistance.steps.ts", "sourceCode": "Given('l\\'événement {string} apparaît sur l\\'accueil de l\\'utilisateur', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {\n const frame = this.appFrame!;\n // Navigate home; if home (participation-filtered) is empty, fall back to\n // /events (Découvrir, no participation filter) — the created event is public.\n await frame.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });", - "lineNumber": 137 + "lineNumber": 138 }, { "pattern": "l'utilisateur ferme et rouvre l'app sous la même identité dans une session broker fraîche", "keyword": "When", "file": "reconnexion-persistance.steps.ts", - "sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});", - "lineNumber": 172 + "sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n undefined,\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});", + "lineNumber": 173 }, { "pattern": "l'événement {string} est toujours présent après reconnexion", "keyword": "Then", "file": "reconnexion-persistance.steps.ts", "sourceCode": "Then('l\\'événement {string} est toujours présent après reconnexion', { timeout: 90000 }, async function (this: FestipodWorld, title: string) {\n const freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n\n // Poll BOTH home (participation-filtered) and /events (Découvrir, public list),\n // re-navigating each attempt so the cold-start union read has time to converge.\n // This is NOT broker-polling (rule_no-broker-polling): the app is reactive; we\n // re-read the RENDERED DOM until the reactive set settles, bounded by timeout.\n const deadline = Date.now() + 60000;\n let found = false;\n while (Date.now() < deadline && !found) {\n for (const path of ['/home', '/events']) {\n await freshFrame.evaluate((p: string) => {\n window.history.pushState(null, '', p);\n window.dispatchEvent(new PopStateEvent('popstate'));\n }, path);\n found = await freshFrame.waitForFunction(\n (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,\n title,\n { timeout: 6000 },\n ).then(() => true).catch(() => false);\n if (found) break;\n }\n }\n\n // --- Report console evidence from BOTH pages regardless of pass/fail ---\n const mainLogs = ((this as any).recoMainLogs ?? []) as StampedLog[];\n const freshLogs = ((this as any).recoFreshLogs ?? []) as StampedLog[];\n const report =\n summarizeLogs('MAIN PAGE (creator)', mainLogs) + '\\n\\n' +\n summarizeLogs('FRESH PAGE (reconnect)', freshLogs) + '\\n\\n' +\n `RESULT: event \"${title}\" ${found ? 'SURVIVED (visible after reconnect)' : 'DISAPPEARED (NOT visible after reconnect)'}`;\n this.attach(report, 'text/plain');\n // Also echo to stdout so it lands in the raw run output.\n console.log('\\n' + report + '\\n');\n\n // Opt-in RAW dump of connection/sync lines (RECO_RAW_DUMP=1) — the evidence\n // that the FRESH page is a genuine cold boot (own WASM worker + own broker\n // handshake), used to argue reconnection FIDELITY. Off by default (noise).\n if (process.env.RECO_RAW_DUMP === '1') {\n const dumpRaw = (label: string, logs: StampedLog[]) => {\n const t0 = logs.length ? logs[0]!.t : Date.now();\n const hits = logs.filter((l) => /peer|CONNECTION|ESTABLISHED|REPLAY|broker|verifier|worker|bootstrap|open_repo|\\bsync\\b/i.test(l.text));\n console.log(`\\n### RAW (${label}) — ${hits.length} connection/sync lines ###`);\n for (const l of hits) console.log(`+${((l.t - t0) / 1000).toFixed(2)}s ${l.text.slice(0, 200)}`);\n };\n dumpRaw('MAIN', mainLogs);\n dumpRaw('FRESH', freshLogs);\n }\n\n if (!found) {\n const debug = await freshFrame.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(`Reconnected fresh page for the SAME identity did NOT show \"${title}\". Path: ${debug.pathname}, content: ${debug.rootText}`);\n }\n});", - "lineNumber": 200 + "lineNumber": 202 }, { "pattern": "je clique sur un événement", @@ -735,7 +735,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "pattern": "l'accueil rend un contenu d'application réel", "keyword": "Then", "file": "accueil-connecte-rend.steps.ts", - "sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});", + "sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n undefined,\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});", "lineNumber": 26 }, { @@ -743,7 +743,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "keyword": "Then", "file": "accueil-connecte-rend.steps.ts", "sourceCode": "Then('aucune erreur runtime n\\'a été émise pendant le boot connecté', function (this: FestipodWorld) {\n // this.pageErrors est peuplé par le hook Before (pageerror + console.error de\n // la page app), réinitialisé à chaque scénario. Un crash de rendu connecté\n // (throw non attrapé dans un composant/provider) émet un `pageerror` et\n // atterrit ici → assertion rouge avec la liste exacte.\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises pendant le boot connecté :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});", - "lineNumber": 65 + "lineNumber": 66 }, { "pattern": "je peux configurer mes notifications", @@ -840,7 +840,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "pattern": "le navigateur {string} est connecté à NextGraph", "keyword": "Then", "file": "multibrowser.steps.ts", - "sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n { timeout: 30000 },\n );\n});", + "sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n undefined,\n { timeout: 30000 },\n );\n});", "lineNumber": 24 }, { diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index 6c24239..ca819cf 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -608,6 +608,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { this.appFrame = this.page!.mainFrame(); await this.appFrame.waitForFunction( () => (window as any).__testData?.ready === true, + undefined, { timeout: 10000 }, ); } @@ -627,6 +628,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { const root = document.getElementById('root'); return root && root.innerHTML.length > 100; }, + undefined, { timeout: 30000 }, );