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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
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 },
);