From 13eb2c4a158cbb7cb428b5c64e20deb5e489039b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 16 Aug 2026 15:16:46 +0200 Subject: [PATCH] Waits that say ten seconds now wait ten seconds, and editing checks you may Two things that announced what they had not verified. Seventeen `waitForFunction` calls passed their timeout in Playwright's ARGUMENT slot instead of its options slot, so every one of them silently used the 30 s default while the code read 5, 10, 15 or 60. The inventory said sixteen: one was a false positive and two more were found that it never listed. All seventeen are corrected, including the nine whose written value is SHORTER than the default. Honouring the author's number is the point: a wait that is too short fails loudly and names its step, where thirty seconds obtained by accident hides a real slowness and reads as a lie in the source. Which of them need raising is a question for the day the suite can run again -- it will be answered on an honest number. The event edit screen awaited nothing: the success toast fired and the screen navigated away whether or not the write resolved. It now confirms after the write, keeps the user on their edits when it fails, and says so. That route was also unguarded -- anyone reaching the URL got the form, for any event. It is now decided by ownership, read from the list of my own documents, with the same three-state answer the pencil icon uses. UNKNOWN renders neither the form nor a bounce: both would present a guess as a fact, and the guess that matters here is telling a genuine owner their event is not theirs. --- .project/concepts/app-architecture/_debt.md | 1 + .project/concepts/app-security/_debt.md | 7 ++ .project/concepts/bdd-testing/_debt.md | 8 +++ src/modules/auth/steps/e2e/connexion.steps.ts | 2 + .../event/screens/UpdateEventScreen.tsx | 65 +++++++++++++++++-- .../reconnexion-froide-sans-local.steps.ts | 1 + .../event/steps/data/reconnexion.steps.ts | 4 +- .../event/steps/e2e/evenement.steps.ts | 6 ++ .../e2e/reconnexion-persistance.steps.ts | 2 + .../steps/e2e/accueil-connecte-rend.steps.ts | 1 + .../workshop/steps/data/multibrowser.steps.ts | 1 + src/shared/data/stepDefinitions.ts | 46 ++++++------- src/shared/support/hooks.ts | 2 + 13 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 .project/concepts/app-security/_debt.md 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 }, );