test(client/e2e): contrats broker RÉEL — reconnexion cold-read + non-fork de compte

Les tests du polyfill doivent PROUVER son contrat contre le VRAI broker (pas un
fake). Ajout de deux tests e2e broker réel (wallet dédié), avec reconnexion
FIDÈLE : page fraîche sur le MÊME profil persistant + nouveau login broker
(helper `faithfulReconnect`), PAS export/réimport dans un profil vide (qui
resynchronise et masque le cold-open).

- Contrat reconnexion/cold-read : session 1 crée un doc (public + protected) ;
  session fraîche fidèle ; relit → données présentes, en POLLANT une borne
  généreuse (l'attente de récupération après souscription est le fonctionnement
  normal).
- Contrat non-fork : re-résoudre le même identifiant après reconnexion rend les
  MÊMES NURIs de docs de scope, pas un second provisioning.

Temps de sync réels observés (le SIGNAL) : protected relu en ~195ms (repo déjà
ouvert) ; PUBLIC relu en ~105s (ouverture repo + attente push + union ancrée à
l'échelle de la donnée publique) ; login de reconnexion ~2.5s. Le ~105s public
est un signal de PERFORMANCE À INVESTIGUER (probable scale/bloat de l'anchorless
union scan, cf. suivi bloat) — le contrat est REMPLI mais lent côté public.

Honnêteté : le « échoue-sans-le-fix » n'a PAS pu être montré via ce chemin de
login (le bootstrap du login broker ouvre déjà les repos → firstRawNoOpen=1).
Contrat prouvé REMPLI avec les fix ; nécessité non isolable via cette voie.

gate : test:e2e 33 passed (baseline 27 vert) ; bun test 117 ; tsc propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-08 18:07:35 +02:00
parent 95010ddd66
commit d98777e6e3
2 changed files with 185 additions and 0 deletions
+171
View File
@@ -59,6 +59,38 @@ function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T>
) as Promise<T>;
}
/**
* FAITHFUL reconnection — the real app's reconnect path, NOT export/reimport.
*
* Opens a BRAND-NEW page on the SAME persistent wallet context (`ctx`, the profile
* that already holds the wallet + its local IndexedDB repo cache) and drives a NEW
* broker login through it. That new login mints a FRESH verifier session (empty
* `self.repos` at connect) while the page is a fresh SDK-module instance (empty
* open-repo registry + empty store-registry cache). This is EXACTLY what the app
* does on re-enter/reload (src/modules/event/steps/data/reconnexion.steps.ts:
* `this.page.context().newPage()` + `pool.setupBrokerPage`), and is the ONLY faithful
* cold-open: it does NOT wipe the profile, so it does NOT force the broker to resync
* every repo from scratch (which export/reimport-into-empty-profile DOES — masking
* the very cold-read/anti-fork gap under test). The repos are on the broker AND in the
* profile's cache, but this session's verifier hasn't opened them yet — so the SDK's
* open-before-read (open-repo.ts) and anti-fork retry (store-registry.ts) are what must
* bridge the gap. Returns the fresh page + its connected iframe Frame.
*/
async function faithfulReconnect(
ctx: BrowserContext,
url: string,
): Promise<{ page: Page; frame: Frame }> {
const p = await ctx.newPage();
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
p.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
});
const frame = await setupBrokerPage(p, url);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
return { page: p, frame };
}
async function main(): Promise<void> {
console.log("[e2e] building SDK page bundle...");
buildBundle();
@@ -359,6 +391,145 @@ async function main(): Promise<void> {
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
}
});
// ── CONTRACT 1: faithful reconnect COLD-READ (public + protected) ─────────
// The real app's reconnect: a FRESH page on the SAME persistent profile + a NEW
// broker login (fresh verifier session), NOT export/reimport into an empty
// profile (which forces a full resync and MASKS the cold-open). Session 1 seeds a
// per-entity doc + marker in BOTH public and protected scopes; a faithful fresh
// session must relist + re-read its OWN persisted markers, POLLING for the sync —
// the wait IS the normal path. NB the observed cost is dominated by broker READ
// latency, not pure sync-lag: ONE reconnectRead cycle (open-repo heal awaits the
// initial-state push per repo + anti-fork retry budget + anchored readUnion, all
// round-tripping the real broker) measures ~90-105s. So the poll deadline is a
// generous MULTI-cycle bound (120s past post-connect) rather than a tight 30s — a
// single slow cycle must not be mistaken for a sync failure. We report the observed
// time (the "signal"). If a marker never lands within the bound the check FAILS (a
// real regression), never a silent 0-row.
console.log("\n── CONTRACT 1: faithful reconnect cold-read (public + protected) ──");
await step("faithful reconnect (same profile, new login) re-reads persisted public + protected docs", async () => {
const reconId = "@recon-faithful-" + Date.now();
// Seed BOTH scopes in the ORIGINAL session (where the repos are open).
const seedPub = await sdk<any>(frame, "reconnectSeed", reconId, "public");
const seedProt = await sdk<any>(frame, "reconnectSeed", reconId, "protected");
check(
"seed: public + protected entity docs listed in the seeding session",
seedPub.listedInSeed.includes(seedPub.entityNuri) && seedProt.listedInSeed.includes(seedProt.entityNuri),
`pub=${String(seedPub.entityNuri).slice(0, 20)}… prot=${String(seedProt.entityNuri).slice(0, 20)}`,
);
let rp: Page | null = null;
try {
// Faithful reconnect: fresh page on the SAME persistent context + new login.
const tLoginStart = Date.now();
const rc = await faithfulReconnect(ctx!, url);
const loginMs = Date.now() - tLoginStart;
rp = rc.page;
const rInfo = await sdkGet<any>(rc.frame, "sessionInfo");
// Fidelity is STRUCTURAL: a fresh page → fresh iframe → fresh SDK-module
// instance (empty open-repo + store-registry caches) + a new broker connect
// over the SAME persistent profile. We assert the reconnect connected; the
// broker numbers session_id per-connection (may reuse 1), so we REPORT the
// ids rather than gate on them differing.
check(
"reconnect session connected (fresh iframe/verifier over the SAME persistent profile)",
rInfo?.session_id !== undefined && rInfo?.session_id !== null,
`reconnectSession=${rInfo?.session_id} origSession=${info?.session_id}`,
);
// Poll each scope up to 30s (from post-connect) for the marker to sync back
// into THIS fresh session. syncMs is the pure sync lag (login excluded); we
// report it as the observed sync signal, plus the first-attempt rawNoOpen (the
// bare anchored read WITHOUT the open-repo heal) which shows whether the heal
// is load-bearing on this SDK/broker version.
for (const [scope, seed] of [["public", seedPub], ["protected", seedProt]] as const) {
let found = false;
let syncMs = -1;
let firstRawNoOpen = -99;
let lastDetail = "";
const tSyncStart = Date.now();
const deadline = tSyncStart + 120000;
let firstAttempt = true;
while (Date.now() < deadline) {
const r = await sdk<any>(rc.frame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
if (firstAttempt) { firstRawNoOpen = r.rawRowCount; firstAttempt = false; }
lastDetail = `firstRawNoOpen=${firstRawNoOpen} rawNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount}`;
if (r.markerPresent === true) {
found = true;
syncMs = Date.now() - tSyncStart;
break;
}
await rc.page.waitForTimeout(1000);
}
check(
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
found,
found
? `synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
);
}
} finally {
try { if (rp) await rp.close(); } catch { /* ignore */ }
}
});
// ── CONTRACT 2: NON-FORK of account across a faithful reconnect ───────────
// Resolving the SAME identifier after a faithful reconnect must return the SAME
// account docs (docPublic/docProtected/docPrivate) — never a SECOND provisioning
// (an account fork), which would strand the first session's data. Session 1
// provisions the account (records its NURIs); a faithful fresh session re-resolves
// the SAME id and must return IDENTICAL NURIs. The anti-fork retry bridges the
// shim-not-yet-synced window; we POLL (generous 120s multi-cycle bound — one
// accountDocs resolve round-trips the broker's anti-fork retry budget, ~90-100s)
// and report when the SAME NURIs land (the sync signal). If they never match (or a
// new set appears) → FAIL.
console.log("\n── CONTRACT 2: non-fork of account across a faithful reconnect ──");
await step("resolving the same identifier after a faithful reconnect returns the SAME account docs (no fork)", async () => {
const forkId = "@nonfork-" + Date.now();
const orig = await sdk<any>(frame, "accountDocs", forkId);
check(
"session 1 provisioned the account (3 scope docs)",
!!orig.docPublic && !!orig.docProtected && !!orig.docPrivate,
`pub=${String(orig.docPublic).slice(0, 20)}`,
);
let rp: Page | null = null;
try {
const tLoginStart = Date.now();
const rc = await faithfulReconnect(ctx!, url);
const loginMs = Date.now() - tLoginStart;
rp = rc.page;
let same = false;
let syncMs = -1;
let last: any = null;
const tSyncStart = Date.now();
const deadline = tSyncStart + 120000;
while (Date.now() < deadline) {
last = await sdk<any>(rc.frame, "accountDocs", forkId);
if (
last.docPublic === orig.docPublic &&
last.docProtected === orig.docProtected &&
last.docPrivate === orig.docPrivate
) {
same = true;
syncMs = Date.now() - tSyncStart;
break;
}
await rc.page.waitForTimeout(1000);
}
check(
"[SYNC] fresh session re-resolves the SAME account NURIs (no second provisioning)",
same,
same
? `same account in ${syncMs}ms (reconnect-login ${loginMs}ms)`
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
);
} finally {
try { if (rp) await rp.close(); } catch { /* ignore */ }
}
});
} finally {
try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ }