From 3a49376f1762c3ea93cd39a7b78172bd0637e54b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 20 Jul 2026 13:16:04 +0200 Subject: [PATCH] =?UTF-8?q?test(reconnexion):=20repro=20=C3=A0=20froid=20s?= =?UTF-8?q?ans=20=C3=A9tat=20local=20(@wip)=20+=20persistance/pause=20(@wi?= =?UTF-8?q?p)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconnexion-froide-sans-local = test décisif broker-vs-local (verdict LOCAL-ONLY), @wip. persistance-e2e + pause @wip. rename identifiant dans reconnexion/isolation/harness-ng. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../reconnexion-froide-sans-local.feature | 34 +++ .../reconnexion-meme-identite.feature | 16 ++ .../reconnexion-persistance-e2e.feature | 32 +++ .../event/steps/data/isolation.steps.ts | 4 +- .../reconnexion-froide-sans-local.steps.ts | 86 ++++++ .../event/steps/data/reconnexion.steps.ts | 94 ++++++- .../e2e/reconnexion-persistance.steps.ts | 264 ++++++++++++++++++ src/shared/test-harness/harness-ng.tsx | 10 +- 8 files changed, 528 insertions(+), 12 deletions(-) create mode 100644 src/modules/event/features/reconnexion-froide-sans-local.feature create mode 100644 src/modules/event/features/reconnexion-persistance-e2e.feature create mode 100644 src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts create mode 100644 src/modules/event/steps/e2e/reconnexion-persistance.steps.ts diff --git a/src/modules/event/features/reconnexion-froide-sans-local.feature b/src/modules/event/features/reconnexion-froide-sans-local.feature new file mode 100644 index 0000000..a7fcf48 --- /dev/null +++ b/src/modules/event/features/reconnexion-froide-sans-local.feature @@ -0,0 +1,34 @@ +# language: fr +@EVENT @priority-1 @data +Fonctionnalité: Reconnexion à froid SANS état local NextGraph (durabilité broker réelle) + En tant qu'utilisateur qui crée un événement puis se reconnecte sous la MÊME + identité depuis un appareil/onglet SANS aucune donnée NextGraph locale, + Je dois relire MON PROPRE événement — sinon c'est qu'il n'a jamais atteint le broker. + + # POURQUOI CE SCÉNARIO (2026-07-14) — question unique qu'il tranche. + # Le scénario @data « reconnexion-meme-identite » reconnecte A via ctx.newPage() + # sur le MÊME contexte persistant (.playwright-profile). Ce contexte détient + # ENCORE les repos de A en IndexedDB LOCAL — un « verifier frais » y rouvre donc + # depuis le LOCAL et ne prouve jamais la durabilité BROKER. C'est le raccourci à + # supprimer. + # + # Ici, A se reconnecte depuis un CONTEXTE NAVIGATEUR FRAIS, NON-PERSISTANT + # (freshBrowser), partition de stockage hermétique séparée de la page d'écriture + # → AUCUN IndexedDB partagé. Le seul état pré-injecté est le storageState du + # wallet partagé capturé au BeforeAll (AVANT que A ne soit créé) : il ne peut donc + # PAS contenir l'événement de A. La seule source possible de l'événement est le + # BROKER. + # + # VERDICT : + # - l'événement REAPPARAÎT → BROKER-DURABLE (l'écriture a atteint le broker). + # - l'événement RESTE ABSENT → LOCAL-ONLY (@data ne peut pas prouver la + # durabilité broker du scope propre de A ; le RED antérieur était un artefact + # local/timing). + # Lecture RÉACTIVE uniquement (waitForFunction sur homeEventTitles), jamais de + # boucle de re-lecture broker (rule_no-broker-polling). + + @wip @data @reco-cold-nolocal + Scénario: A relit son propre événement après une reconnexion à froid sans état local + Étant donné que l'identité A crée l'événement "Événement froid sans local de A" et s'y inscrit + Quand un navigateur frais non-persistant recharge pour la MÊME identité A avec le wallet partagé + Alors l'événement "Événement froid sans local de A" est sur l'accueil de la page fraîche A diff --git a/src/modules/event/features/reconnexion-meme-identite.feature b/src/modules/event/features/reconnexion-meme-identite.feature index f618851..3f14c8f 100644 --- a/src/modules/event/features/reconnexion-meme-identite.feature +++ b/src/modules/event/features/reconnexion-meme-identite.feature @@ -34,3 +34,19 @@ Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant Alors l'événement "Événement de reconnexion de A" est sur l'accueil de la page fraîche A Et la page fraîche A est participante de l'événement "Événement de reconnexion de A" Et le compte autoritatif de participation de A à l'événement "Événement de reconnexion de A" est 1 + + # REPRODUCTION par ATTENTE SIMPLE (aucune déconnexion injectée) : identique au + # scénario ci-dessus, mais une PAUSE sépare l'écriture (création + inscription) + # de la reconnexion fraîche. Hypothèse sous test : le socket broker meurt tout + # seul pendant une pause (SOCKET IS CLOSED … SerializationError observé en + # Firefox réel), l'écriture n'atteint jamais durablement le broker, et la + # reconnexion relit alors un événement disparu (Err(TopicNotFound) / REPLAY + # TOPIC NOT FOUND / readScopeIndex → 0). RED attendu si le socket meurt en env + # de test ; PASS si le socket de test survit à la pause (résultat négatif + # valide — la mort spontanée serait alors spécifique à Firefox réel). + @wip @data @reconnexion-pause + Scénario: Une pause avant la reconnexion ne doit pas faire perdre l'événement + Étant donné que l'identité A crée l'événement "Événement de reconnexion de A (pause)" et s'y inscrit + Quand on attend 20 secondes sans aucune activité + Et une page fraîche pour la MÊME identité A recharge sur le même wallet + Alors l'événement "Événement de reconnexion de A (pause)" finit par apparaître sur la page fraîche A en laissant jusqu'à 60 secondes à la barrière avec rechargements diff --git a/src/modules/event/features/reconnexion-persistance-e2e.feature b/src/modules/event/features/reconnexion-persistance-e2e.feature new file mode 100644 index 0000000..0a5b26d --- /dev/null +++ b/src/modules/event/features/reconnexion-persistance-e2e.feature @@ -0,0 +1,32 @@ +# language: fr +@EVENT @priority-1 +Fonctionnalité: Persistance d'un événement à la reconnexion (app réelle, @e2e) + En tant qu'utilisateur qui, dans la VRAIE app, crée un événement puis FERME + et ROUVRE l'app sous la MÊME identité (même wallet, session verifier fraîche), + Je dois retrouver mon événement à la reconnexion + Afin que rien ne disparaisse quand je reviens. + + # POURQUOI CE SCÉNARIO EXISTE (2026-07-13) + # Bug rapporté en condition RÉELLE : user1 crée un événement (visible), ferme et + # se reconnecte (même identité, même wallet) → l'événement a DISPARU ; la console + # montre `REPLAY TOPIC NOT FOUND` en masse, un re-provisioning (fork) de compte, + # et l'ancien docPublic relit vide. + # + # Le scénario @data « reconnexion-meme-identite » PASSE, mais il charge un HARNESS + # de test, PAS l'app réelle — il ne valide donc pas le parcours de l'utilisateur. + # Ce scénario @e2e boote la VRAIE app dans l'iframe broker (setupBrokerPage), + # crée l'événement via le VRAI formulaire, puis ouvre une SECONDE page/session + # broker FRAÎCHE pour la MÊME identité — le miroir fidèle de « fermer et rouvrir ». + # + # HYPOTHÈSE À TESTER (NON présumée vraie) : les écritures faites avant que le + # broker soit vraiment connecté partiraient dans une outbox non-durable → jamais + # acceptées → REPLAY TOPIC NOT FOUND à la reconnexion → perte. Le step de + # reconnexion CAPTURE les logs console des DEUX pages (timing des `WRITE` vs + # `CONNECTION ESTABLISHED`, occurrences de `REPLAY TOPIC NOT FOUND`) comme preuve. + + @e2e @wip + Scénario: Un événement créé survit à une reconnexion fidèle de la même identité + Étant donné que l'utilisateur crée un événement "Événement persistant e2e" via le vrai formulaire + Et l'événement "Événement persistant e2e" apparaît sur l'accueil de l'utilisateur + Quand l'utilisateur ferme et rouvre l'app sous la même identité dans une session broker fraîche + Alors l'événement "Événement persistant e2e" est toujours présent après reconnexion diff --git a/src/modules/event/steps/data/isolation.steps.ts b/src/modules/event/steps/data/isolation.steps.ts index 433fe60..765b96d 100644 --- a/src/modules/event/steps/data/isolation.steps.ts +++ b/src/modules/event/steps/data/isolation.steps.ts @@ -4,13 +4,13 @@ import type { FestipodWorld } from '../../../../shared/support/world'; import { pool } from '../../../../shared/support/browserPool'; // Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario -// username set in localStorage) creates an event E and joins it; a genuinely- +// identifier set in localStorage) creates an event E and joins it; a genuinely- // different identity B is brought up on the SAME wallet; B must read NONE of A's // protected participation, E must not be on B's home, isParticipating(E,B) false. // // B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's // identifier in localStorage — the closest analogue to the real app's re-enter- -// gate / reload path (a brand-new NgDataProvider mount, username=B, on a wallet +// gate / reload path (a brand-new NgDataProvider mount, identifier=B, on a wallet // that already holds A's docs). This exercises the identity-switch reset that // keeps A's protected docs out of B's read set. diff --git a/src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts b/src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts new file mode 100644 index 0000000..b39d615 --- /dev/null +++ b/src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @@ -0,0 +1,86 @@ +import { When } from '@cucumber/cucumber'; +import type { FestipodWorld } from '../../../../shared/support/world'; +import { pool, spawnContext } from '../../../../shared/support/browserPool'; + +// RECONNEXION À FROID SANS ÉTAT LOCAL (@data, broker réel). Tranche la question +// unique : A relit-il son propre événement DEPUIS LE BROKER, ou depuis l'IndexedDB +// LOCAL du profil persistant ? Le scénario « reconnexion-meme-identite » reconnecte +// A via ctx.newPage() sur le MÊME contexte persistant — ce contexte détient encore +// les repos de A en local, donc un « verifier frais » y rouvre depuis le local et +// ne prouve jamais la durabilité broker. Ici on supprime ce raccourci. +// +// GARANTIE « no-local » (ce qui rend le verdict valide) : +// 1. La page d'écriture est le contexte PERSISTANT (.playwright-profile). +// 2. La reconnexion utilise un contexte issu de `freshBrowser` (chromium.launch +// NON-persistant) → process séparé, partition de stockage HERMÉTIQUE (garantie +// Playwright, isolation prouvée jusqu'à l'origine broker nextgraph.net par +// knowledge_multibrowser-harness). Il ne partage AUCUN IndexedDB avec la page +// d'écriture. +// 3. Le seul état pré-injecté est `pool.sharedWalletState`, capturé au BeforeAll, +// AVANT que ce scénario ne crée l'événement de A. Le snapshot ne peut donc PAS +// contenir l'événement de A. +// ⇒ Le contexte de reconnexion n'a AUCUNE copie locale de l'événement fraîchement +// créé par A ; sa seule source possible est le BROKER. +// +// Réutilise le Given « l'identité A crée l'événement {string} et s'y inscrit » +// (isolation.steps.ts, écrit sur la page persistante this.appFrame) et le Then +// « l'événement {string} est sur l'accueil de la page fraîche A » (reconnexion.steps.ts, +// lecture RÉACTIVE via waitForFunction sur homeEventTitles — jamais de polling broker). + +When( + 'un navigateur frais non-persistant recharge pour la MÊME identité A avec le wallet partagé', + { timeout: 120000 }, + async function (this: FestipodWorld) { + // SAME identity A: the per-scenario virtual identifier set by the Before hook. + const aIdentifier = (this as any).freshIdentifier as string; + if (!pool.sharedWalletState) { + throw new Error( + 'sharedWalletState non capturé au BeforeAll — impossible de provisionner un ' + + 'contexte frais avec le wallet partagé A. Sans lui, PAS de reconnexion no-local ' + + '(le verdict serait invalide). STOP.', + ); + } + + // FRESH, non-persistent, hermetic context seeded with ONLY the shared wallet + // storageState (captured before A's event existed). Separate storage partition + // from the persistent write page → no shared IndexedDB, no local copy of A's + // just-created event. Its ONLY source for A's event is the broker. + const ctx = await spawnContext('shared'); + (this as any).recoColdCtx = ctx; // closed by freshBrowser.close() in AfterAll + const freshPage = await ctx.newPage(); + + // SAME identity A: inject A's app-level identifier on every origin BEFORE any + // script (incl. the harness iframe on 127.0.0.1), so the shim keys to the SAME + // virtual account A — a reconnect, not an identity switch. Identical injection + // to isolation.steps.ts / reconnexion.steps.ts, but into a FRESH context. + await freshPage.addInitScript((u: string) => { + try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ } + }, aIdentifier); + await freshPage.addInitScript(() => { + (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; + }); + + // Broad console capture (ALL types) so the SDK diagnostic lines + // (BARRIER synced|timed-out, OUTBOX, readScopeIndex → N, CONNECTION ESTABLISHED, + // REPLAY TOPIC NOT FOUND, …) surface verbatim to stdout as the verdict's proof. + freshPage.on('console', (msg) => { console.log(`[ColdFreshA:${msg.type()}]`, msg.text()); }); + freshPage.on('pageerror', (err) => console.error('[ColdFreshA pageerror]', err.message)); + + // New broker login on the SAME shared wallet → fresh verifier session whose + // local repos are EMPTY for A's event (this fresh context never held it). This + // is exactly the cold-start read path the reconnect must heal from the broker. + const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!); + await freshFrame.waitForFunction( + () => (window as any).__testData?.ready === true, + { timeout: 60000 }, + ); + // Resolve A's principal (profile read hydrated) before the reactive Then reads. + await freshFrame.evaluate(async () => { + const td = (window as any).__testData; + await td.ensureCurrentUser(); + }); + + (this as any).recoFreshFrame = freshFrame; + (this as any).recoFreshPage = freshPage; + }, +); diff --git a/src/modules/event/steps/data/reconnexion.steps.ts b/src/modules/event/steps/data/reconnexion.steps.ts index a94026b..033381d 100644 --- a/src/modules/event/steps/data/reconnexion.steps.ts +++ b/src/modules/event/steps/data/reconnexion.steps.ts @@ -11,25 +11,104 @@ import { pool } from '../../../../shared/support/browserPool'; // listing path (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries // repos not yet in `self.repos` at cold-start and silently returns 0 rows. // -// Identity A is the scenario's fresh virtual-wallet username (this.freshUser, set +// Identity A is the scenario's fresh virtual-wallet identifier (this.freshIdentifier, set // by the Before hook into localStorage on every origin). A creates E via the REAL // app path (createEventReal) and joins it (appJoinEvent) on the MAIN page. Then a // FRESH PAGE is brought up on the SAME persistent wallet context with the SAME // identifier A in localStorage BEFORE any script — the closest analogue to the real // app's re-enter-gate / reload path (a brand-new NgDataProvider mount + a fresh // broker session that must re-open A's own repos). Montage identical to -// isolation.steps.ts, except the fresh page reuses this.freshUser (SAME A) rather +// isolation.steps.ts, except the fresh page reuses this.freshIdentifier (SAME A) rather // than minting a new identifier B. // The Given "l'identité A crée l'événement {string} et s'y inscrit" is REUSED from // isolation.steps.ts (same wording, same behavior — A creates E and joins on the // main page). It stores this.isoEventId / this.isoAId, which the steps below read. +// REPRODUCTION step (no injected disconnect): a plain, passive wait on the SAME +// main page A just wrote through. The hypothesis under test is that the broker +// socket dies SPONTANEOUSLY during an idle pause (observed in real Firefox as +// "SOCKET IS CLOSED … SerializationError") — we do NOT provoke it. A broad +// console listener (ALL message types, not just 'error') is attached here so the +// signature lines (socket-closed, TopicNotFound, REPLAY TOPIC NOT FOUND, +// readScopeIndex → 0) are captured verbatim to stdout regardless of the console +// method the SDK/wasm layer used to emit them. +When('on attend {int} secondes sans aucune activité', { timeout: 60000 }, async function (this: FestipodWorld, seconds: number) { + const page = this.page!; + const onConsole = (msg: import('playwright').ConsoleMessage) => { + console.log(`[PauseWatch:${msg.type()}] ${msg.text()}`); + }; + page.on('console', onConsole); + console.log(`[PauseWatch] starting a ${seconds}s PASSIVE pause (no disconnect injected) — watching for a spontaneous socket death…`); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + console.log(`[PauseWatch] ${seconds}s pause complete — proceeding to the fresh reconnection.`); + // Leave the listener attached: the signature may also surface once the fresh + // page's reconnection kicks the outbox (SENDING EVENTS FROM OUTBOX …). +}); + +// DIAGNOSTIC (real loss vs read-timeout): after the fresh reconnection reads the +// home EMPTY, keep giving the sync barrier more time — up to ~60s — and force a +// couple of FULL RELOADS of the fresh page. Each reload remounts NgDataProvider +// and re-opens A's repos, i.e. a BRAND-NEW barrier attempt (open-repo.ts). We poll +// the REACTIVE home set (homeEventTitles reads AD() fed by the subscription push — +// NOT a broker re-read; this is the reactive state a real reloading user watches), +// recording the exact elapsed ms at which the title appears (if ever). The verdict: +// - title APPEARS within 60s → READ-TIMEOUT (data IS on the broker, the 8s +// bootstrap barrier was just too short / needed a remount to re-sync). +// - title ABSENT after 60s + reloads → REAL LOSS (the write never became durable). +// The broad console listener attached in the reconnection step keeps flowing the +// `BARRIER … synced|timed-out` lines to stdout across the reloads. +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) { + const freshPage = (this as any).recoFreshPage as import('playwright').Page; + let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame; + const startedAt = Date.now(); + const BUDGET_MS = 60000; + const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks + let reloadIdx = 0; + let appearedAtMs = -1; + + const readHome = async (): Promise => { + try { + return await freshFrame.evaluate((t: string) => { + const td = (window as any).__testData; + return td && td.homeEventTitles ? td.homeEventTitles() : []; + }, title); + } catch { return []; } + }; + + while (Date.now() - startedAt < BUDGET_MS) { + const elapsed = Date.now() - startedAt; + const titles = await readHome(); + if (titles.includes(title)) { appearedAtMs = elapsed; break; } + // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier. + if (reloadIdx < reloadAtMs.length && elapsed >= reloadAtMs[reloadIdx]) { + reloadIdx++; + 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.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); }); + (this as any).recoFreshFrame = freshFrame; + } catch (e) { + console.log(`[LongPoll] reload #${reloadIdx} failed: ${(e as Error).message}`); + } + } + await new Promise((r) => setTimeout(r, 2000)); + } + + if (appearedAtMs >= 0) { + console.log(`[LongPoll] VERDICT = READ-TIMEOUT — "${title}" APPEARED at t=${appearedAtMs}ms (data was on the broker; the bootstrap barrier was just too short).`); + } else { + console.log(`[LongPoll] VERDICT = REAL LOSS — "${title}" still ABSENT after ${BUDGET_MS}ms and ${reloadIdx} reload(s) (write never became durable).`); + } + expect(appearedAtMs, `"${title}" should eventually appear within ${BUDGET_MS}ms if the data reached the broker (RED here ⇒ real loss)`).to.be.at.least(0); +}); + When('une page fraîche pour la MÊME identité A recharge sur le même wallet', { timeout: 120000 }, async function (this: FestipodWorld) { // SAME identity A as the main page: reuse the scenario's fresh virtual-wallet - // username (set by the Before hook). NOT a new identifier — this is a reconnect, + // identifier (set by the Before hook). NOT a new identifier — this is a reconnect, // not an identity switch. - const aIdentifier = (this as any).freshUser as string; + const aIdentifier = (this as any).freshIdentifier as string; const ctx = this.page!.context(); const freshPage = await ctx.newPage(); await freshPage.addInitScript((u: string) => { @@ -38,7 +117,11 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet', await freshPage.addInitScript(() => { (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; }); - freshPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[FreshApage console]', msg.text()); }); + // Broadened to ALL console types (not just 'error') for the pause-reproduction + // investigation: the SDK's diagnostic lines (logStage: BARRIER/OUTBOX/ + // readScopeIndex) are emitted via console.log, not console.error, and would + // otherwise be invisible here — purely diagnostic, does not affect assertions. + 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 }); @@ -50,6 +133,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet', await new Promise(r => setTimeout(r, 6000)); }); (this as any).recoFreshFrame = freshFrame; + (this as any).recoFreshPage = freshPage; }); // The cold-start read is a BOUNDED SYNC-LAG (measured: the just-created public diff --git a/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts b/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts new file mode 100644 index 0000000..8dc3274 --- /dev/null +++ b/src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @@ -0,0 +1,264 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; +import { pool } from '../../../../shared/support/browserPool'; + +// RECONNECTION-PERSISTENCE at the @e2e layer (REAL app, real broker). +// +// Mirrors reconnexion.steps.ts (@data) but drives the REAL app (pool.appUrl), +// NOT the harness. The main page is booted by the @e2e Before hook: identity = +// this.freshIdentifier (set into localStorage['festipod.account.identifier'] on every +// origin by the hook), gate disabled → the real app boots directly on that +// identity. We create an event via the REAL create form (same DOM path the app +// user takes), verify it appears, then open a SECOND page in the SAME persistent +// wallet context with the SAME identifier + gate disabled, and a FRESH broker +// login → a fresh verifier session (empty memory) that must re-read everything +// from the broker. That is the faithful analogue of "close and reopen". +// +// The step captures console logs from BOTH pages and reports (via cucumber +// attachments) the timing of `WRITE`-ish lines vs `CONNECTION ESTABLISHED`, and +// any `REPLAY TOPIC NOT FOUND` — the evidence for/against the offline-write +// hypothesis. NO fix is implemented here — observation only. + +interface StampedLog { t: number; text: string; } + +function attachConsoleCapture(page: import('playwright').Page, bucket: StampedLog[]): void { + page.on('console', (msg) => { + const text = msg.text(); + bucket.push({ t: Date.now(), text }); + }); +} + +function summarizeLogs(label: string, logs: StampedLog[]): string { + const t0 = logs.length ? logs[0]!.t : Date.now(); + const rel = (t: number) => `+${((t - t0) / 1000).toFixed(2)}s`; + + const isConnected = (s: string) => /CONNECTION ESTABLISHED WITH peer/i.test(s); + const isWrite = (s: string) => /\bWRITE\b/i.test(s) || /\[polyfill\].*write/i.test(s); + const isReplayMiss = (s: string) => /REPLAY TOPIC NOT FOUND/i.test(s); + const isResolveAccount = (s: string) => /resolveAccount/i.test(s); + const isReadScope = (s: string) => /readScopeIndex/i.test(s); + + const connectedAt = logs.filter((l) => isConnected(l.text)).map((l) => l.t); + const writes = logs.filter((l) => isWrite(l.text)); + const replayMisses = logs.filter((l) => isReplayMiss(l.text)); + const resolveAccounts = logs.filter((l) => isResolveAccount(l.text)); + const readScopes = logs.filter((l) => isReadScope(l.text)); + + const firstConnected = connectedAt.length ? Math.min(...connectedAt) : null; + + const lines: string[] = []; + lines.push(`===== ${label} — console summary (${logs.length} lines) =====`); + lines.push(`CONNECTION ESTABLISHED WITH peer: ${connectedAt.length} occurrence(s)` + + (firstConnected != null ? ` — first at ${rel(firstConnected)}` : '')); + lines.push(`WRITE-ish lines: ${writes.length}`); + if (writes.length && firstConnected != null) { + const before = writes.filter((w) => w.t < firstConnected).length; + const after = writes.length - before; + lines.push(` WRITE before first CONNECTION ESTABLISHED: ${before}`); + lines.push(` WRITE after first CONNECTION ESTABLISHED: ${after}`); + } else if (writes.length && firstConnected == null) { + lines.push(` (no CONNECTION ESTABLISHED seen — cannot classify WRITE timing)`); + } + lines.push(`REPLAY TOPIC NOT FOUND: ${replayMisses.length} occurrence(s)`); + lines.push(`resolveAccount lines: ${resolveAccounts.length}`); + lines.push(`readScopeIndex lines: ${readScopes.length}`); + + const showcase = (title: string, arr: StampedLog[], n: number) => { + if (!arr.length) return; + lines.push(`--- ${title} (first ${Math.min(n, arr.length)}) ---`); + for (const l of arr.slice(0, n)) lines.push(` ${rel(l.t)} ${l.text.slice(0, 240)}`); + }; + showcase('WRITE lines', writes, 8); + showcase('REPLAY TOPIC NOT FOUND lines', replayMisses, 8); + showcase('resolveAccount lines', resolveAccounts, 6); + return lines.join('\n'); +} + +// --- Step 1: create the event via the REAL create form on the main page --- + +Given('l\'utilisateur crée un événement {string} via le vrai formulaire', { timeout: 90000 }, async function (this: FestipodWorld, title: string) { + // Start capturing console on the MAIN page from BEFORE the create write, so we + // observe whether the create WRITE lands before/after CONNECTION ESTABLISHED. + const mainLogs: StampedLog[] = []; + (this as any).recoMainLogs = mainLogs; + attachConsoleCapture(this.page!, mainLogs); + + const frame = this.appFrame!; + + // Navigate to the real create form and fill it via the DOM, exactly like the + // real app user (mirrors evenement.steps.ts). 3-step wizard. + await frame.evaluate(() => { + window.history.pushState(null, '', '/events/new'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + const formReady = await frame.waitForFunction( + () => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'), + { timeout: 15000 }, + ).then(() => true).catch(() => false); + if (!formReady) { + const debug = await frame.evaluate(() => ({ + pathname: window.location.pathname, + rootText: document.getElementById('root')?.textContent?.substring(0, 300), + })); + throw new Error(`Create form not found. Path: ${debug.pathname}, content: ${debug.rootText}`); + } + + // Step 1: name + start date + await frame.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title); + await frame.locator('input[type="date"]').first().fill('2026-08-15'); + await frame.locator('button', { hasText: 'Suivant' }).first().click(); + await frame.waitForTimeout(500); + + // Possible step 2 (similar-event warning) → Next again + const onStep2 = await frame.evaluate( + () => document.body.textContent?.includes('Événement similaire détecté') ?? false, + ); + if (onStep2) { + await frame.locator('button', { hasText: 'Suivant' }).first().click(); + await frame.waitForTimeout(500); + } + + // Step 3: time + place, then submit + await frame.locator('input[type="time"]').first().fill('14:00').catch(() => {}); + await frame.locator('input[placeholder="Ajouter un lieu"]').fill('Parc Bordelais, Bordeaux').catch(() => {}); + + const submit = frame.locator('button', { hasText: 'Relayer l\'événement' }); + await submit.first().click(); + await frame.waitForTimeout(2000); + + // Confirm we left the form (detail or home) — sanity that the create went through. + await frame.waitForFunction( + (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false, + title, + { timeout: 15000 }, + ).catch(() => { /* asserted more strictly by the next step */ }); +}); + +Given('l\'événement {string} apparaît sur l\'accueil de l\'utilisateur', { timeout: 60000 }, async function (this: FestipodWorld, title: string) { + const frame = this.appFrame!; + // Navigate home; if home (participation-filtered) is empty, fall back to + // /events (Découvrir, no participation filter) — the created event is public. + await frame.evaluate(() => { + window.history.pushState(null, '', '/home'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + let seen = await frame.waitForFunction( + (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false, + title, + { timeout: 15000 }, + ).then(() => true).catch(() => false); + if (!seen) { + await frame.evaluate(() => { + window.history.pushState(null, '', '/events'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + seen = await frame.waitForFunction( + (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false, + title, + { timeout: 20000 }, + ).then(() => true).catch(() => false); + } + if (!seen) { + const debug = await frame.evaluate(() => ({ + pathname: window.location.pathname, + rootText: document.getElementById('root')?.textContent?.substring(0, 400), + })); + expect.fail(`Created event "${title}" not visible before reconnect. Path: ${debug.pathname}, content: ${debug.rootText}`); + } +}); + +// --- Step 2: reconnect faithfully — a fresh page/session for the SAME identity --- + +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) { + const identifier = (this as any).freshIdentifier as string; + const ctx = this.page!.context(); + + const freshPage = await ctx.newPage(); + // SAME identity in localStorage BEFORE any script (what the reopened app reads, + // gate disabled) + gate disabled so the real app boots straight onto that id. + await freshPage.addInitScript((u: string) => { + try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ } + }, identifier); + await freshPage.addInitScript(() => { + (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; + }); + + const freshLogs: StampedLog[] = []; + (this as any).recoFreshLogs = freshLogs; + attachConsoleCapture(freshPage, freshLogs); + freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` })); + + // NEW broker login → fresh verifier session on the SAME persistent wallet. + const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!); + // Wait for the real app to render. + await freshFrame.waitForFunction( + () => { + const root = document.getElementById('root'); + return !!root && root.innerHTML.length > 100; + }, + { timeout: 60000 }, + ); + // Let NG connect + the cold-start read path run. + await freshFrame.waitForTimeout(6000); + (this as any).recoFreshFrame = freshFrame; + (this as any).recoFreshPage = freshPage; +}); + +Then('l\'événement {string} est toujours présent après reconnexion', { timeout: 90000 }, async function (this: FestipodWorld, title: string) { + const freshFrame = (this as any).recoFreshFrame as import('playwright').Frame; + + // Poll BOTH home (participation-filtered) and /events (Découvrir, public list), + // re-navigating each attempt so the cold-start union read has time to converge. + // This is NOT broker-polling (rule_no-broker-polling): the app is reactive; we + // re-read the RENDERED DOM until the reactive set settles, bounded by timeout. + const deadline = Date.now() + 60000; + let found = false; + while (Date.now() < deadline && !found) { + for (const path of ['/home', '/events']) { + await freshFrame.evaluate((p: string) => { + window.history.pushState(null, '', p); + window.dispatchEvent(new PopStateEvent('popstate')); + }, path); + found = await freshFrame.waitForFunction( + (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false, + title, + { timeout: 6000 }, + ).then(() => true).catch(() => false); + if (found) break; + } + } + + // --- Report console evidence from BOTH pages regardless of pass/fail --- + const mainLogs = ((this as any).recoMainLogs ?? []) as StampedLog[]; + const freshLogs = ((this as any).recoFreshLogs ?? []) as StampedLog[]; + const report = + summarizeLogs('MAIN PAGE (creator)', mainLogs) + '\n\n' + + summarizeLogs('FRESH PAGE (reconnect)', freshLogs) + '\n\n' + + `RESULT: event "${title}" ${found ? 'SURVIVED (visible after reconnect)' : 'DISAPPEARED (NOT visible after reconnect)'}`; + this.attach(report, 'text/plain'); + // Also echo to stdout so it lands in the raw run output. + console.log('\n' + report + '\n'); + + // Opt-in RAW dump of connection/sync lines (RECO_RAW_DUMP=1) — the evidence + // that the FRESH page is a genuine cold boot (own WASM worker + own broker + // handshake), used to argue reconnection FIDELITY. Off by default (noise). + if (process.env.RECO_RAW_DUMP === '1') { + const dumpRaw = (label: string, logs: StampedLog[]) => { + const t0 = logs.length ? logs[0]!.t : Date.now(); + const hits = logs.filter((l) => /peer|CONNECTION|ESTABLISHED|REPLAY|broker|verifier|worker|bootstrap|open_repo|\bsync\b/i.test(l.text)); + console.log(`\n### RAW (${label}) — ${hits.length} connection/sync lines ###`); + for (const l of hits) console.log(`+${((l.t - t0) / 1000).toFixed(2)}s ${l.text.slice(0, 200)}`); + }; + dumpRaw('MAIN', mainLogs); + dumpRaw('FRESH', freshLogs); + } + + if (!found) { + const debug = await freshFrame.evaluate(() => ({ + pathname: window.location.pathname, + rootText: document.getElementById('root')?.textContent?.substring(0, 500), + })); + expect.fail(`Reconnected fresh page for the SAME identity did NOT show "${title}". Path: ${debug.pathname}, content: ${debug.rootText}`); + } +}); diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 260cb32..136c9a6 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -356,7 +356,7 @@ function ConnectedHarness() { // Enumerate the CURRENT account's own protected docs — the read-by-need // path the APP uses (registration.countUserParticipations → // listMyEntityDocs), NOT the all-accounts `listEntityDocs` fan-out. Each - // @data scenario runs under a FRESH virtual account (freshScenarioUsername + // @data scenario runs under a FRESH virtual account (freshScenarioIdentifier // in localStorage), whose participation docs live ONLY in that account's // protected scope index. The all-accounts fan-out (`allAccounts()`) does // not surface the fresh account here (its registry record isn't in the @@ -489,7 +489,7 @@ function ConnectedHarness() { const priv = `did:ng:${session.private_store_id}`; const SHIM = 'urn:ng-eventually:shim'; const t0 = Date.now(); - // Delete every Account record (and its username/doc* predicates) from the + // Delete every Account record (and its identity/doc* predicates) from the // anchor graph. `?p ?o` with the `a shim:Account` guard scopes the delete // strictly to registry triples, leaving anything else in the private // store intact. @@ -611,13 +611,13 @@ function ConnectedHarness() { * (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via * SPARQL SELECT. Validates: doc_create ×3 + shim sparql_update/query. */ - async validateShim(username: string) { + async validateShim(identifier: string) { const reg = await import('../utils/storeRegistry'); reg.resetRegistryCache(); - const created = await reg.ensureAccount(username); + const created = await reg.ensureAccount(identifier); reg.resetRegistryCache(); const reloaded = (await reg.allAccounts()).find( - a => a.id === username, + a => a.id === identifier, ) ?? null; return { created, reloaded }; },