test(reconnexion): repro à froid sans état local (@wip) + persistance/pause (@wip)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string, unknown>).__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;
|
||||
},
|
||||
);
|
||||
@@ -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<string[]> => {
|
||||
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<string, unknown>).__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
|
||||
|
||||
@@ -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<string, unknown>).__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}`);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user