From eafb4403b9097f2b410614c8b5df98f48a46568e Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 5 Jul 2026 13:28:08 +0200 Subject: [PATCH] test(data): per-scenario state isolation to bound the read fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @data oscillated 15-20/21 because the persistent test wallet accumulated data across scenarios, growing the read fan-out. Add a cheap per-scenario reset (resetDataState): a single SPARQL DELETE on the shim anchor graph clears the account records, so allAccounts() collapses and the fan-out is bounded to what the current scenario re-provisions (accounts recreated lazily). O(1) on one graph — not a fan-out delete (which saturated the browser before). Called in the @data Before hook, time-boxed so it can't starve the broker login budget. Test-infra only — product model, boundary and app read path untouched. Note: not yet re-measured to stable-green — the broker was degraded during the bounded validation window (DNS/timeout flakiness). To re-measure when the broker is stable. knowledge_data-layer-broker updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledge_data-layer-broker.md | 22 ++++++--- src/shared/support/hooks.ts | 20 ++++++++ src/shared/test-harness/harness-ng.tsx | 49 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/.project/concepts/bdd-testing/knowledge_data-layer-broker.md b/.project/concepts/bdd-testing/knowledge_data-layer-broker.md index 16bb752..23bff44 100644 --- a/.project/concepts/bdd-testing/knowledge_data-layer-broker.md +++ b/.project/concepts/bdd-testing/knowledge_data-layer-broker.md @@ -1,7 +1,7 @@ --- type: knowledge summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock -last_checked: 2026-07-03 +last_checked: 2026-07-05 --- # Couche `@data` (broker réel) @@ -50,9 +50,17 @@ Cucumber → Playwright (Chromium, profil persistant) `ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation soit relue. -- **Caveat wallet persistant** : le wallet partagé **accumule** les docs per-entité à chaque run - (seed + inscriptions). Le fan-out de lecture (`listEntityDocs`) parcourt tous les docs de tous - les comptes → ralentit et fait *timeouter* les steps quand le wallet est pollué. Pour une suite - fiable, repartir d'un wallet **frais** (supprimer `.playwright-profile/` → recréation - automatique) ; le seed connecté est volontairement **allégé** (peu de docs) car chaque - `docCreate` est un aller-retour broker sériel ~2s. +- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule** + le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture + (`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes + → ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté + broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force + une re-auth lente — mauvais levier. À la place, le `Before` @data appelle + `window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store) + qui efface tous les records `urn:ng-eventually:shim:Account` → `allAccounts()` s'effondre à vide → + le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement + par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur, + cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le + budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni + le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de + docs) car chaque `docCreate` est un aller-retour broker sériel ~2s. diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index 9b01978..d226bc9 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -578,6 +578,26 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { () => (window as any).__testData?.ready === true, { timeout: 30000 }, ); + + // PER-SCENARIO STATE ISOLATION (T03.j). The @data suite shares ONE + // persistent broker-backed wallet, so the emulated account registry + // ACCUMULATES every account any prior scenario/run created — growing the + // read fan-out (`allAccounts()` → per-account `listEntityDocs`) until it + // gets slow and flaky. Purge the registry anchor once here so each @data + // scenario starts from a CLEAN registry and the fan-out stays bounded to + // what this scenario re-provisions. A single SPARQL DELETE on ONE graph — + // not a fan-out delete. + // HARD-BOUNDED (≤10s): this reset shares the Before hook's 60s budget with + // the (already slow, intermittent) broker login. It must NEVER contend for + // that budget — a slow purge on a hugely-accumulated anchor graph, or a + // broker stall, is swallowed and the scenario proceeds (its own steps still + // gate on state). So race it against a 10s cap and never let it throw. + await Promise.race([ + this.appFrame.evaluate(async () => { + try { await (window as any).__testData?.resetDataState?.(); } catch { /* best-effort */ } + }), + new Promise((r) => setTimeout(r, 10000)), + ]).catch(() => { /* best-effort */ }); } else { // Mock mode: load harness directly await this.page!.setContent('
'); diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index cd7806a..da485f9 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -366,6 +366,55 @@ function ConnectedHarness() { return { cleared: all.length }; }, + /** + * PER-SCENARIO STATE ISOLATION (T03.j). The @data suite runs against ONE + * persistent broker-backed wallet, so the emulated account registry (the + * `urn:ng-eventually:shim:Account` triples in the private-store anchor + * graph) ACCUMULATES every account any scenario/run ever provisioned. The + * read path is a fan-out: `allAccounts()` → one SPARQL SELECT per account + * for `listEntityDocs`. As the registry grows unbounded across runs, that + * fan-out gets slow and flaky (same class as the T03.d Chromium saturation). + * + * This gives each @data scenario a CLEAN registry: a SINGLE SPARQL DELETE + * on the ONE private-store anchor graph removes every accumulated Account + * record, so `allAccounts()` collapses to empty and the fan-out is bounded + * to whatever the CURRENT scenario re-provisions (accounts are lazily + * re-created by `ensureAccount` on first use). It is O(1) on ONE graph — NOT + * a fan-out delete (which saturated the browser before, see T03.i's removed + * `authClearParticipation`). Orphaned per-entity docs are simply never + * enumerated once their owning Account record is gone. + * + * Test-infra ONLY: touches the emulation's registry anchor, never the + * product model, the app read path, or the boundary. The lib is untouched; + * this reuses the same anchor NURI (`did:ng:${private_store_id}`) and shim + * vocabulary the lib's `loadShim`/`ensureAccount` use. + */ + async resetDataState() { + const reg = await import('../utils/storeRegistry'); + 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 + // 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. + const del = ` + DELETE { GRAPH <${priv}> { ?acc ?p ?o } } + WHERE { + GRAPH <${priv}> { + ?acc a <${SHIM}:Account> ; + ?p ?o . + } + }`; + try { + await docs.sparqlUpdate(session.session_id, del, priv); + } catch { /* best-effort — a broker flake must not fail the scenario */ } + // Drop the in-memory account cache so the next registry call re-reads the + // now-empty anchor (else a stale cache would keep the old accounts alive). + reg.resetRegistryCache(); + return { resetMs: Date.now() - t0 }; + }, + /** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of * historical inbox-deposit triples across test runs (the old inbox anchor * = private store), making `loadShim` a 60s+ full-graph scan. Delete every