diff --git a/src/modules/event/steps/data/createur.steps.ts b/src/modules/event/steps/data/createur.steps.ts index 54558ef..f6d5e13 100644 --- a/src/modules/event/steps/data/createur.steps.ts +++ b/src/modules/event/steps/data/createur.steps.ts @@ -19,18 +19,22 @@ Given('le créateur relaie l\'événement {string}', { timeout: 180000 }, async Then('le créateur n\'est pas participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) { const eventId = (this as any).creatorEventId; - // Authoritative: the broker must hold 0 participations for (E, creator). Poll a - // little to absorb any pending write from a just-run leave. + // Wait for the REACTIVE state to settle to "not participating" (observes AD().* + // fed by the subscription push — NOT a broker re-read), the sign the sync has + // passed. Then do ONE authoritative broker read to prove the 0 is durable. + await this.appFrame!.waitForFunction( + (eventId) => { + const td = (window as any).__testData; + const uid = td.currentUserId; + return !!uid && !td.isParticipating(eventId, uid); + }, + eventId, + { timeout: 30000 }, + ).catch(() => { /* fall through to the single authoritative read */ }); const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => { const td = (window as any).__testData; const uid = await td.ensureCurrentUser(); - let last = -1; - for (let i = 0; i < 8; i++) { - last = await td.authParticipationCount(eventId, uid); - if (last === 0) return 0; - await new Promise(r => setTimeout(r, 750)); - } - return last; + return td.authParticipationCount(eventId, uid); }, { eventId }); expect(n, `creator must NOT be participating in "${title}" (broker count)`).to.equal(0); }); @@ -68,16 +72,22 @@ When('le créateur quitte son événement {string}', { timeout: 120000 }, async Then('le créateur est participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) { const eventId = (this as any).creatorEventId; + // Wait for the REACTIVE join to settle (observes AD().isParticipating fed by the + // subscription push — NOT a broker re-read), then take ONE authoritative broker + // count to prove the participation is durable at the data level. + await this.appFrame!.waitForFunction( + (eventId) => { + const td = (window as any).__testData; + const uid = td.currentUserId; + return !!uid && td.isParticipating(eventId, uid); + }, + eventId, + { timeout: 45000 }, + ).catch(() => { /* fall through to the single authoritative read */ }); const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => { const td = (window as any).__testData; const uid = await td.ensureCurrentUser(); - let last = 0; - for (let i = 0; i < 20; i++) { - last = await td.authParticipationCount(eventId, uid); - if (last >= 1) return last; - await new Promise(r => setTimeout(r, 1000)); - } - return last; + return td.authParticipationCount(eventId, uid); }, { eventId }); expect(n, `creator must be participating in "${title}" after joining`).to.equal(1); }); diff --git a/src/modules/event/steps/data/inscription-inbox.steps.ts b/src/modules/event/steps/data/inscription-inbox.steps.ts index 14fc35f..4adbf5e 100644 --- a/src/modules/event/steps/data/inscription-inbox.steps.ts +++ b/src/modules/event/steps/data/inscription-inbox.steps.ts @@ -88,6 +88,9 @@ Then('l\'utilisateur devient participant de l\'événement {string}', async func // Poll: the participation is written into its own protected doc and read back // reactively; under a busy wallet that read can lag, so accept the AUTHORITATIVE // broker count as well (the write is durable regardless of the reactive re-read). + // Wait for the REACTIVE participation to settle (observes AD().liveIsParticipating + // fed by the subscription push — NOT a broker re-read). Generous window: this + // reactive convergence IS the sync-passed signal. await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; @@ -97,23 +100,16 @@ Then('l\'utilisateur devient participant de l\'événement {string}', async func }, eventTitle, { timeout: 45000 }, - ).catch(async () => { - // Reactive read lagged — confirm authoritatively against the broker, polling - // to absorb the index-append propagation lag of the per-entity fan-out. - const n = await this.appFrame!.evaluate(async (title) => { - const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - if (!event) return 0; - const uid = await td.ensureCurrentUser(); - for (let i = 0; i < 12; i++) { - const c = await td.authParticipationCount(event['@id'], uid); - if (c > 0) return c; - await new Promise(r => setTimeout(r, 1500)); - } - return 0; - }, eventTitle); - expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0); - }); + ).catch(() => { /* fall through to a single authoritative confirmation */ }); + // Then ONE authoritative broker read to prove the participation is durable. + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return 0; + const uid = await td.ensureCurrentUser(); + return td.authParticipationCount(event['@id'], uid); + }, eventTitle); + expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0); }); Then('le broker ne contient plus aucune participation à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { diff --git a/src/modules/event/steps/data/inscription.steps.ts b/src/modules/event/steps/data/inscription.steps.ts index 04f2f78..7df4b89 100644 --- a/src/modules/event/steps/data/inscription.steps.ts +++ b/src/modules/event/steps/data/inscription.steps.ts @@ -97,6 +97,9 @@ Then('l\'utilisateur est participant de l\'événement {string}', async function // The participation is written into its own protected document and read back // reactively — poll (the read lags the write against the broker), resolving the // current user id at call time. + // Wait for the REACTIVE participation to settle (observes AD().isParticipating fed + // by the subscription push — NOT a broker re-read). Generous window: this reactive + // convergence IS the sync-passed signal. await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; @@ -106,24 +109,18 @@ Then('l\'utilisateur est participant de l\'événement {string}', async function return !!event && td.isParticipating(event['@id'], uid); }, eventTitle, - { timeout: 30000 }, - ).catch(async () => { - // Reactive read lagged — confirm authoritatively against the broker, polling - // to absorb the index-append propagation lag of the per-entity fan-out. - const n = await this.appFrame!.evaluate(async (title) => { - const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - if (!event) return 0; - const uid = await td.ensureCurrentUser(); - for (let i = 0; i < 12; i++) { - const c = await td.authParticipationCount(event['@id'], uid); - if (c > 0) return c; - await new Promise(r => setTimeout(r, 1500)); - } - return 0; - }, eventTitle); - expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0); - }); + { timeout: 45000 }, + ).catch(() => { /* fall through to a single authoritative confirmation */ }); + // Then ONE authoritative broker read (bypasses the reactive set) to prove the + // participation is durable at the data level. + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return 0; + const uid = await td.ensureCurrentUser(); + return td.authParticipationCount(event['@id'], uid); + }, eventTitle); + expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0); }); Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { @@ -163,17 +160,27 @@ Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', // (The absolute 1→2 convergence stays proven end-to-end by the @multibrowser // reactive scenario with a real owner A + joiner B.) Poll (the deposit's index // append + broker sync lag), bounded. + // First wait for the REACTIVE join to settle (observes AD().isParticipating fed by + // the subscription push — the sign the deposit's write path has run — NOT a broker + // re-read). THEN materialize the owner's derived active set from the broker ONCE. + await this.appFrame!.waitForFunction( + (title) => { + const td = (window as any).__testData; + const uid = td.currentUserId; + if (!uid) return false; + const ev = [...td.events].find((e: any) => e.title === title); + return !!ev && td.isParticipating(ev['@id'], uid); + }, + eventTitle, + { timeout: 45000 }, + ).catch(() => { /* fall through to the single authoritative materialization */ }); const inActive = await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const ev = [...td.events].find((e: any) => e.title === title); const uid = await td.ensureCurrentUser(); if (!ev || !uid) return false; - for (let i = 0; i < 20; i++) { - const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']); - if (users.includes(uid)) return true; - await new Promise(r => setTimeout(r, 750)); - } - return false; + const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']); + return users.includes(uid); }, eventTitle); expect(inActive, `the just-joined user must be in the owner-derived active set for "${eventTitle}"`).to.be.true; }); @@ -221,19 +228,27 @@ Then('l\'inscription est idempotente pour l\'événement {string}', async functi // and its index-append propagates async, so a single unpolled read can catch 0 // before the write is queryable (observed flake) — poll until the durable state // (exactly 1) is visible, which also proves the second join did NOT add a dupe. + // First wait for the REACTIVE participation to settle (observes AD().isParticipating + // fed by the subscription push — the sync-passed signal — NOT a broker re-read). + // THEN read the AUTHORITATIVE broker count ONCE: exactly 1 proves the second join + // added no dupe (bypasses reactive-read lag). + await this.appFrame!.waitForFunction( + (title) => { + const td = (window as any).__testData; + const uid = td.currentUserId; + if (!uid) return false; + const event = [...td.events].find((e: any) => e.title === title); + return !!event && td.isParticipating(event['@id'], uid); + }, + eventTitle, + { timeout: 45000 }, + ).catch(() => { /* fall through to the single authoritative count */ }); const n = await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); if (!event) return -1; const uid = await td.ensureCurrentUser(); - let last = 0; - for (let i = 0; i < 20; i++) { - last = await td.authParticipationCount(event['@id'], uid); - if (last === 1) return 1; // exactly one — idempotent, stop early - if (last > 1) return last; // a dupe leaked — fail fast with the real count - await new Promise(r => setTimeout(r, 1000)); - } - return last; + return td.authParticipationCount(event['@id'], uid); }, eventTitle); expect(n, 'User should have exactly one participation record on the broker').to.equal(1); }); diff --git a/src/modules/event/steps/data/isolation.steps.ts b/src/modules/event/steps/data/isolation.steps.ts index 62d2250..3e10fd7 100644 --- a/src/modules/event/steps/data/isolation.steps.ts +++ b/src/modules/event/steps/data/isolation.steps.ts @@ -20,14 +20,18 @@ Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout: const aId = await td.ensureCurrentUser(); const created = await td.createEventReal(title); await td.appJoinEvent(created.id, aId); - // Wait until A's own participation is in A's reactive set (authoritative-ish). - for (let i = 0; i < 30; i++) { - if (td.isParticipating(created.id, aId)) break; - await new Promise(r => setTimeout(r, 500)); - } - return { eventId: created.id, aId, aParticipates: td.isParticipating(created.id, aId) }; + return { eventId: created.id, aId }; }, title); - expect(out.aParticipates, 'A must be participating in its own event before B arrives').to.be.true; + // OBSERVE the REACTIVE set until A's own join has settled (isParticipating reads + // AD() fed by the subscription push — NOT a broker re-read). This is the barrier + // that A's participation exists before B arrives; no broker polling. + const aParticipates = await this.appFrame!.waitForFunction( + ({ eventId, aId }: { eventId: string; aId: string }) => + (window as any).__testData.isParticipating(eventId, aId), + { eventId: out.eventId, aId: out.aId }, + { timeout: 45000 }, + ).then(() => true).catch(() => false); + expect(aParticipates, 'A must be participating in its own event before B arrives').to.be.true; (this as any).isoEventId = out.eventId; (this as any).isoEventTitle = title; (this as any).isoAId = out.aId; diff --git a/src/modules/event/steps/data/reconnexion.steps.ts b/src/modules/event/steps/data/reconnexion.steps.ts index 264fa86..ace164c 100644 --- a/src/modules/event/steps/data/reconnexion.steps.ts +++ b/src/modules/event/steps/data/reconnexion.steps.ts @@ -59,45 +59,58 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet', // flake. This mirrors how the app's own steps already poll participation. Then('l\'événement {string} est sur l\'accueil de la page fraîche A', { timeout: 60000 }, async function (this: FestipodWorld, title: string) { const freshFrame = (this as any).recoFreshFrame; - const onHome = await freshFrame.evaluate(async (title: string) => { - const td = (window as any).__testData; - for (let i = 0; i < 30; i++) { - if (td.homeEventTitles().includes(title)) return true; - await new Promise(r => setTimeout(r, 500)); - } - return td.homeEventTitles().includes(title); - }, title); + // OBSERVE the REACTIVE home set (homeEventTitles reads AD().getUserEvents fed by + // the subscription push — NOT a broker re-read). waitForFunction re-evaluates the + // reactive getter until the cold-start union read converges; no broker polling. + const onHome = await freshFrame.waitForFunction( + (title: string) => (window as any).__testData.homeEventTitles().includes(title), + title, + { timeout: 30000 }, + ).then(() => true).catch(() => false); expect(onHome, `"${title}" MUST appear on the fresh A page's home (getUserEvents(A) after reconnect)`).to.be.true; }); Then('la page fraîche A est participante de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) { const freshFrame = (this as any).recoFreshFrame; const eventId = (this as any).isoEventId; - const isPart = await freshFrame.evaluate(async ({ eventId }: { eventId: string }) => { - const td = (window as any).__testData; - const aId = await td.ensureCurrentUser(); - for (let i = 0; i < 30; i++) { - if (td.isParticipating(eventId, aId)) return true; - await new Promise(r => setTimeout(r, 500)); - } - return td.isParticipating(eventId, aId); - }, { eventId }); + await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); }); + // OBSERVE the REACTIVE participation set (isParticipating reads AD() fed by the + // subscription push — NOT a broker re-read). waitForFunction re-evaluates the + // reactive getter until the cold-start union read converges; no broker polling. + const isPart = await freshFrame.waitForFunction( + (eventId: string) => { + const td = (window as any).__testData; + const aId = td.currentUserId; + return !!aId && td.isParticipating(eventId, aId); + }, + eventId, + { timeout: 30000 }, + ).then(() => true).catch(() => false); expect(isPart, `The fresh A page MUST read its own participation in "${title}" after reconnect`).to.be.true; }); Then('le compte autoritatif de participation de A à l\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) { const freshFrame = (this as any).recoFreshFrame; const eventId = (this as any).isoEventId; - const count = await freshFrame.evaluate(async ({ eventId, expected }: { eventId: string; expected: number }) => { + await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); }); + // First wait for the REACTIVE state to reflect the expectation (the sign the + // cold-start sync has passed — observes AD().isParticipating fed by the push, NOT + // a broker re-read). THEN do ONE authoritative broker read. + await freshFrame.waitForFunction( + ({ eventId, expected }: { eventId: string; expected: number }) => { + const td = (window as any).__testData; + const aId = td.currentUserId; + if (!aId) return false; + const isPart = td.isParticipating(eventId, aId); + return expected > 0 ? isPart : !isPart; + }, + { eventId, expected }, + { timeout: 30000 }, + ).catch(() => { /* fall through to the single authoritative read */ }); + const count = await freshFrame.evaluate(async ({ eventId }: { eventId: string }) => { const td = (window as any).__testData; const aId = await td.ensureCurrentUser(); - let last = -1; - for (let i = 0; i < 30; i++) { - last = await td.authParticipationCount(eventId, aId); - if (last === expected) return last; - await new Promise(r => setTimeout(r, 500)); - } - return last; - }, { eventId, expected }); + return td.authParticipationCount(eventId, aId); + }, { eventId }); expect(count, `Authoritative broker count of A's participation must be ${expected} after reconnect`).to.equal(expected); });