test(@data): dé-poller les steps — attendre l'état réactif, plus de boucle broker

Applique rule_no-broker-polling aux steps @data : les boucles
`for (i<N){ authParticipationCount()/getEventParticipants(); sleep }` (re-lecture
broker) sont remplacées par le pattern correct — attendre que l'ÉTAT RÉACTIF se
settle (waitForFunction sur isParticipating/homeEventTitles, alimentés par le push
watchShape, PAS de lecture broker dans la boucle), PUIS UNE lecture autoritative
unique quand l'assertion vérifie la vérité broker.

Aucune assertion affaiblie. Un poll masquait un cold-read cassé en « lent-mais-vert »
→ ce pattern expose les vraies non-convergences. Vérifié : rien n'est exposé (ça
converge dans les bornes) et le baseline reste vert.

Steps : createur, reconnexion, isolation, inscription, inscription-inbox.
gate : bun run validate = ALL STEPS PASSED ; tsc propre.
Suivi : multibrowser-features.steps.ts:85 (readInboxDeposits dans un waitForFunction)
= même classe, hors scope @data, passe dédiée à faire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-10 17:57:13 +02:00
parent 0fc8479e22
commit 4ffa055d62
5 changed files with 136 additions and 98 deletions
+26 -16
View File
@@ -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);
});
@@ -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) {
@@ -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);
});
@@ -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;
@@ -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);
});