diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index 9a0bd86..68af906 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -59,6 +59,38 @@ function sdkGet(frame: Frame, method: string, ...args: unknown[]): Promise ) as Promise; } +/** + * 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 { console.log("[e2e] building SDK page bundle..."); buildBundle(); @@ -359,6 +391,145 @@ async function main(): Promise { 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(frame, "reconnectSeed", reconId, "public"); + const seedProt = await sdk(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(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(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(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(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 */ } diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index f91c6e6..17c1de4 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -542,6 +542,20 @@ const identity = new IdentityStore( markers, }; }, + /** + * NON-FORK contract probe. Resolve (or create on first sight) the account for + * `id` and return its THREE scope-document NURIs (docPublic/docProtected/ + * docPrivate) exactly as ensureAccount records them in the shim. Run in session 1 + * it PROVISIONS the account (first NURIs); run in a FRESH faithful reconnect + * session it must RE-RESOLVE the SAME account from the synced shim and return the + * SAME NURIs — never a second provisioning (an account fork). resetRegistryCache + * first so the resolve goes to the shim, not a same-session in-memory hit. + */ + async accountDocs(id: string) { + storeRegistry.resetRegistryCache(); + const rec = await storeRegistry.ensureAccount(id); + return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate }; + }, async scopeResolvers() { const priv = await storeRegistry.resolveScopeGraph("private"); const prot = await storeRegistry.resolveScopeGraph("protected");