Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
8 changed files with 528 additions and 12 deletions
Showing only changes of commit 3a49376f17 - Show all commits
@@ -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}`);
}
});
+5 -5
View File
@@ -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 };
},