/** * Real-broker e2e runner for `@ng-eventually/polyfill` — the polyfill's OWN suite, * in the SDK domain (no application concepts), with a DEDICATED wallet. * * Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite. * Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/polyfill). * * It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens * the broker iframe on the real broker with that wallet, waits for `window.__sdk` * to connect, then drives every polyfill behavior through the bridge and asserts * the real-broker outcomes. Each check is event-driven where reactivity matters * (waitForFunction on a counter), never a blind sleep. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Frame, Page, BrowserContext } from "playwright"; import { buildBundle, serveHarness, closeContext, ensureWallet, launchWalletContext, launchCleanProfileContext, importWalletViaFile, newPage, setupBrokerPage, PROFILE_DIR, } from "./broker"; import { armSuiteDeadline, closeQuietly, within } from "./deadline"; import { acquireRunLock } from "./run-lock"; type Check = { name: string; ok: boolean; detail?: string }; const results: Check[] = []; function record(name: string, ok: boolean, detail?: string): void { results.push({ name, ok, detail }); const tag = ok ? "PASS" : "FAIL"; console.log(` [${tag}] ${name}${detail ? " — " + detail : ""}`); } function check(name: string, cond: boolean, detail?: string): void { record(name, !!cond, detail); } /** * One step of the batch, under its own deadline. * * The bound is what makes the catch honest: recording a FAIL is the right answer for a step * that fails, but a step that never RETURNS is caught by nothing — and from the outside * that is indistinguishable from a machine that has stopped. */ async function step(name: string, fn: () => Promise): Promise { try { await within(`the step "${name}"`, STEP_MS, fn); } catch (e: any) { record(name, false, "threw: " + String(e?.message ?? e)); } } /** * Call a bridge method inside the iframe — under a deadline, because `frame.evaluate()` * has none. * * This is the single most important bound in the file: every one of the thirty-odd * `sdk(...)` calls below is an `evaluate`, and Playwright will wait on one for ever. A * bridge method that never settles — a broker round-trip that gets no answer — used to * stop the batch dead with nothing printed and no way to tell which call it was. The name * carried into the error is the method's own, so the report says which. */ function sdk(frame: Frame, method: string, ...args: unknown[]): Promise { return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () => frame.evaluate( ([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])), [method, args] as const, ), ) as Promise; } function sdkGet(frame: Frame, method: string, ...args: unknown[]): Promise { // Same as sdk() but for synchronous getters (no await inside the bridge). return sdk(frame, method, ...args); } /** * 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 newPage("the faithful reconnect", ctx); 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 }; } /** * The batch's own budget, and the measurement that explains an overrun. * * Not a `timeout` wrapped around the command from outside: when that fired it killed the * browser, and the suite reported `Target page, context or browser has been closed` — * which reads as an application bug and was twice diagnosed as one. A budget belongs to * the thing that knows what it is spending it on, and it must say so when it runs out. * * ENFORCED WHILE IT RUNS, not merely checked at the end. Declared as 45 min and only ever * asserted after the last step, it could not fire on the one case that matters — a batch * that never reaches its last step. 25 min is the number now, against a healthy batch of * ~3.3 min: the drift the old figure was sized against (a physical user growing across * batches, so an O(size) cold resync) is gone since each batch mints its own user, and a * budget that cannot interrupt anything is not a budget. */ const BATCH_BUDGET_MS = 25 * 60 * 1000; /** * One `__sdk` bridge call. The slowest measured on this broker is a reconnect read at * ~90-105s (open-repo heal + anti-fork retry + anchored read, all round-tripping); four * minutes is well past that and still names a stuck call in minutes rather than never. */ const BRIDGE_MS = 4 * 60 * 1000; /** * One step. The longest are the reconnect contracts, which poll for up to 120s per scope * on top of a fresh broker login — a few minutes when healthy, ten before we call it stuck. */ const STEP_MS = 10 * 60 * 1000; const batchStart = Date.now(); /** * The slowest cold resynchronisation of the batch — the number that drifted from 250s to * 286s over a month without anyone looking, because it only ever appeared inside one * step's detail line. It is the health indicator of the physical user, so it is reported * with the summary. */ let coldSyncMs = 0; /** Fail with the cause named, rather than letting a killed browser look like a defect. */ function assertWithinBudget(): void { const spent = Date.now() - batchStart; if (spent > BATCH_BUDGET_MS) { throw new Error( `[e2e] batch budget exceeded (${Math.round(spent / 60000)} min > ` + `${BATCH_BUDGET_MS / 60000} min). This is almost always the physical user having ` + "grown: a cold resync is O(its size). Check the cold-sync figure printed above — " + "it should be stable from batch to batch now that each gets a fresh wallet.", ); } } async function main(): Promise { // Before anything touches the shared profile: this batch is about to DELETE it (see // `ensureWallet`), so a second run alive right now would be destroyed by this one. acquireRunLock("the polyfill suite (e2e/run.ts)", PROFILE_DIR); armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS); console.log("[e2e] building SDK page bundle..."); buildBundle(); console.log("[e2e] ensuring dedicated lib wallet..."); await ensureWallet(); const { url, close: closeServer } = await serveHarness(); console.log(`[e2e] harness served at ${url}`); let ctx: BrowserContext | null = null; let page: Page | null = null; try { ctx = await launchWalletContext("sdk-harness"); page = await newPage("the SDK harness", ctx); page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("console", (m) => { if (m.type() === "error") console.error("[iframe console]", m.text()); }); console.log("[e2e] loading SDK page in broker iframe..."); const frame = await setupBrokerPage(page, url); // Wait for the bridge to exist + the broker session to connect. await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000, }); const info = await sdkGet(frame, "sessionInfo"); check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`); // ── access gate ───────────────────────────────────────────────────────── console.log("\n── access gate ──"); await step("the gate asks on a first access, and settles the identity normalized", async () => { const r = await sdk(frame, "accessGateFirstVisit", "@Erin"); check( "barrier shown, Entrer disabled while empty, identity normalized, barrier removed", r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false, `shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`, ); }); await step("the gate stays away when the identity is already known", async () => { const r = await sdk(frame, "accessGateReturningVisit", "erin"); check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`); }); // ── docs primitives ───────────────────────────────────────────────────── console.log("\n── docs primitives ──"); await step("docCreate returns a usable NURI", async () => { const nuri = await sdk(frame, "docCreate"); check("docCreate returns a usable NURI", typeof nuri === "string" && nuri.length > 0, nuri); }); await step("SPARQL graph-behavior characterization (a/b/c)", async () => { const rt = await sdk(frame, "docRoundTrip"); // (a) THE load-bearing assertion fake-ng cannot verify: the anchored default- // graph write (no GRAPH clause) round-trips through the real broker's // repo_graph_name overlay. This is the canonical shape the lib writes, and // what read-model / inbox / store-registry all rely on. check( "(a) anchored default-graph write (no GRAPH) ROUND-TRIPS", rt.anchoredPresent === true, `predicates=${JSON.stringify(rt.predicates)}`, ); // (b) FINDING (reported, not gating): on THIS broker version an explicit // `INSERT DATA { GRAPH {…} }` ANCHORED to the same doc ALSO // round-trips — readable both via the anchored default-graph read // (explicitGraphPresent) AND via an explicit `GRAPH ` read // (explicitViaNamedGraph). i.e. when anchored, the plain NURI resolves to the // SAME repo graph — there is NO "phantom graph" here. The lib still writes the // no-GRAPH default-graph shape as the always-safe canonical convention; this // records what the broker actually does so the "phantom graph" comments can be // re-checked against this broker version by re-running this harness. record( "(b) [finding] explicit GRAPH ANCHORED resolves to the same repo (no phantom graph)", true, `defaultGraphRead=${rt.explicitGraphPresent} namedGraphRead=${rt.explicitViaNamedGraph} (informational)`, ); // (c) FINDING: the ANCHORLESS `GRAPH ?g { … }` union scan spans EVERY named // graph in the session store (it saw BOTH doc A's and doc B's graphs). This is // the O(wallet-size) cost the read path avoids by reading each doc with its own // anchored default-graph query. Reported, not gating (it is a perf property of // the union, not a correctness assertion of the lib's write/read shape). const us = rt.unionSpan ?? {}; record( "(c) [finding] anchorless GRAPH ?g scan spans ALL named graphs (O(wallet) union)", true, us.graphCount === -1 ? `anchorless scan errored: ${us.error} (union claim NOT re-verified here)` : `sawDocA=${us.sawDocA} sawDocB=${us.sawDocB} graphCount=${us.graphCount} (both ⇒ union spans all graphs)`, ); }); // ── read-model ────────────────────────────────────────────────────────── console.log("\n── read-model ──"); await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => { // 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone // could not distinguish grouping-by-document from grouping-by-subject, which is why // this step stayed green while `readUnion` conflated them (fixed 2026-08-10). const r = await sdk(frame, "readUnionOverDocs", 3, false); const iris: string[] = r.subjectIris ?? []; check( "one entry per subject, not per document", r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"), `entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`, ); check( "each entry carries the subject it was written under, not the document", iris.every((s) => s.startsWith("urn:e2e:rm:")), JSON.stringify(iris), ); check( "…and its `graph` is the document reference", (r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")), JSON.stringify(r.graphs), ); }); await step("readUnion per-doc tolerance (bad NURI skipped)", async () => { const r = await sdk(frame, "readUnionOverDocs", 2, true); // 2 documents, the last carrying two subjects → 3 entries. check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`); }); await step("readUnion cap gate", async () => { const r = await sdk(frame, "readUnionCapGate"); check("cap gate drops doc for stranger, keeps for owner", r.strangerCount === 0 && r.ownerCount === 1, `stranger=${r.strangerCount} owner=${r.ownerCount}`); }); // ── reactivity (doc_subscribe) ────────────────────────────────────────── console.log("\n── reactivity (doc_subscribe) ──"); await step("subscribeDoc initial + on-write", async () => { const { doc } = await sdk(frame, "subscribeDocStart", "h1"); // initial state push (event-driven wait) await frame.waitForFunction(() => (window as any).__sdk.subscribeCount("h1") >= 1, { timeout: 20000 }); const initial = await sdkGet(frame, "subscribeCount", "h1"); check("subscribeDoc fires on initial state", initial >= 1, `count=${initial}`); // subsequent real write → another push await sdk(frame, "writeTo", doc, "w1"); await frame.waitForFunction( (base) => (window as any).__sdk.subscribeCount("h1") > (base as number), initial, { timeout: 20000 }, ); const afterWrite = await sdkGet(frame, "subscribeCount", "h1"); check("subscribeDoc fires on a subsequent write", afterWrite > initial, `count=${afterWrite} (>${initial})`); // unsubscribe stops callbacks await sdk(frame, "subscribeStop", "h1"); const frozen = await sdkGet(frame, "subscribeCount", "h1"); await sdk(frame, "writeTo", doc, "w2"); await page!.waitForTimeout(3000); const afterUnsub = await sdkGet(frame, "subscribeCount", "h1"); check("unsubscribe stops callbacks", afterUnsub === frozen, `count stayed ${afterUnsub}`); }); await step("subscribeDocs per-doc isolation", async () => { const { good } = await sdk(frame, "subscribeDocsStart"); await frame.waitForFunction(() => (window as any).__sdk.multiSubCounts().good >= 1, { timeout: 20000 }); const base = await sdkGet(frame, "multiSubCounts"); await sdk(frame, "writeTo", good, "mw1"); await frame.waitForFunction( (b) => (window as any).__sdk.multiSubCounts().good > (b as number), base.good, { timeout: 20000 }, ); const after = await sdkGet(frame, "multiSubCounts"); check("good doc fires despite a dead doc in the set", after.good > base.good, `good=${after.good} bad=${after.bad}`); await sdk(frame, "multiSubStop"); }); // ── inbox ─────────────────────────────────────────────────────────────── console.log("\n── inbox ──"); await step("inbox post → read round-trip", async () => { // Fresh user per run: an inbox is stable for its owner, so a reused id would // read back the previous runs' deposits too (the wallet persists). const r = await sdk(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { k: "a" }, { k: "b" }); const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload)); check( "post then read returns both deposits (sorted)", r.deposits.length === 2 && payloads.includes('{"k":"a"}') && payloads.includes('{"k":"b"}'), `deposits=${r.deposits.length}`, ); }); await step("inbox watch fires on deposit", async () => { await sdk(frame, "inboxWatchStart", "@watcher-" + Date.now()); await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 }); const base = await sdkGet(frame, "inboxWatchState"); await sdk(frame, "inboxWatchDeposit", { landed: true }); await frame.waitForFunction( (b) => (window as any).__sdk.inboxWatchState().fires > (b as number), base.fires, { timeout: 20000 }, ); const after = await sdkGet(frame, "inboxWatchState"); check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`); await sdk(frame, "inboxWatchStop"); }); // MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's // note, and only Alice reads it"). This is the step that motivated that suite: it was // green here while the feature was unusable, because the harness could hand the inbox // address across an identity boundary through a variable — a channel no application // has. Driven through two screens, the address has to be FOUND or the journey fails. await step("inbox spoof guard", async () => { const r = await sdk(frame, "inboxSpoofGuard"); check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`); }); // ── store-registry ────────────────────────────────────────────────────── console.log("\n── store-registry ──"); await step("ensureAccount idempotent", async () => { const r = await sdk(frame, "ensureAccountIdempotent", "@alice-" + Date.now()); check("ensureAccount returns the same 3 docs on repeat", r.same === true, `same=${r.same}`); }); await step("createEntityDoc + listMyEntityDocs bounded to one account", async () => { const t = Date.now(); const r = await sdk(frame, "entityDocsBounded", "@ea-" + t, "@eb-" + t); check("listMyEntityDocs lists A's docs and does NOT leak B's", r.hasA1 && r.hasA2 && !r.leaksB, `A1=${r.hasA1} A2=${r.hasA2} leaksB=${r.leaksB} listA=${r.listA.length}`); }); await step("scope resolvers", async () => { const r = await sdk(frame, "scopeResolvers"); check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`); }); // ── watchShape (reactive useQuery-shaped read) ────────────────────────── // The real cycle: first subscription reads isPending (barrier not yet crossed), // then after the broker sync it reaches isSuccess with the seeded datum present. // A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the // distinction useShape's upgrade will make native, surfaced here from getSyncState). console.log("\n── watchShape (reactive useQuery-shaped read) ──"); await step("watchShape: first subscribe isPending → isSuccess with data present", async () => { const h = "cyc" + Date.now(); const CLS = "urn:e2e:ws:Event"; const seed = await sdk(frame, "watchShapeSeedAndSubscribe", h, CLS); check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`); // Event-driven: wait for the barrier to cross + the datum to land. await frame.waitForFunction( (hh) => { const s = (window as any).__sdk.watchShapeSnapshot(hh as string); return s && s.isSuccess && s.dataLen >= 1; }, h, { timeout: 30000 }, ); const snap = await sdkGet(frame, "watchShapeSnapshot", h); check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`); await sdk(frame, "watchShapeStop", h); }); await step("watchShape: empty scope reaches isSuccess with data:[]", async () => { const h = "empty" + Date.now(); const CLS = "urn:e2e:ws:Event"; await sdk(frame, "watchShapeEmptyStart", h, CLS); await frame.waitForFunction( (hh) => { const s = (window as any).__sdk.watchShapeSnapshot(hh as string); return s && s.isSuccess; }, h, { timeout: 30000 }, ); const snap = await sdkGet(frame, "watchShapeSnapshot", h); check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`); await sdk(frame, "watchShapeStop", h); }); // ── caps / read-filter (in-memory cap model) ──────────────────────────── console.log("\n── caps / read-filter (in-memory cap model) ──"); await step("read-filter: you read what your keyring holds, nothing else", async () => { const r = await sdk(frame, "capsReadFilter"); // The owner reads the documents whose caps their keyring holds — and NOT the // one it does not, even though its NURI is right there in the set. const ownerReadsHeld = r.ownerView.includes("protected-item") && r.ownerView.includes("public-item"); const ownerMissesUnheld = !r.ownerView.includes("unheld-item"); // A stranger holds nothing at all — a bare reference names without reading. const strangerReadsNothing = r.strangerView.length === 0; // …until the repo link of the PUBLISHED document reaches them. const linkOpensPublic = r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item"); check( "the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it", ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`, ); }); // MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays // shut until she gives Bob the key"): sharing is a journey, and it is worth more // driven through two screens than through two calls on one page. // ── accounts (IdentityStore) ──────────────────────────────────────────── console.log("\n── accounts (IdentityStore) ──"); await step("IdentityStore set/get/clear", async () => { const setr = await sdk(frame, "identitySet", "@ident-user"); const got = await sdkGet(frame, "identityGet"); const cleared = await sdk(frame, "identityClear"); check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`); }); // ── reconnection cold-start (real-broker regression) ───────────────────── // Phase 1 (THIS session): seed — create a per-entity doc under (id, protected) // and write a marker triple (`protected` carries participations). Also EXPORT the // wallet `.ngw` so phase 2 can re-import the SAME wallet into a CLEAN browser // profile. // // Phase 2 (the faithful reconnect): import the wallet into a BRAND-NEW empty // profile dir (no local IndexedDB copy of the repos) and open a fresh SDK session // over it — the repos exist on the BROKER but NOT in this profile's local cache, // so this is a true reconnect (not a same-profile relaunch, which masks the gap by // eagerly rehydrating repos from local storage). Then re-read purely from the // wallet and assert the marker comes back. // // FINDING (recorded, see the digest): on THIS SDK/broker version the marker also // comes back WITHOUT the open-before-read heal — the broker-login bootstrap opens // the user's repos before the read (the `rawAnchoredNoOpen` detail below shows the // bare anchored query already resolves rows). So this test is a real-broker // REGRESSION guard for reconnection reads, NOT a fail-without-the-fix proof; the // cold-start the fix targets was diagnosed in the app and does not reproduce // through this harness's login path. console.log("\n── reconnection cold-start (real-broker regression) ──"); await step("fresh session (clean profile, same wallet) re-reads a persisted entity doc", async () => { const reconId = "@recon-" + Date.now(); const scope = "protected"; const seed = await sdk(frame, "reconnectSeed", reconId, scope); check( "seed: entity doc created + listed in the seeding session", seed.listedInSeed.includes(seed.entityNuri), `entity=${String(seed.entityNuri).slice(0, 24)}… listedInSeed=${seed.listedInSeed.length} origSession=${info?.session_id}`, ); // Export the wallet file and materialize it for the clean-profile import. const exp = await sdk(frame, "exportWalletFile"); const ngwPath = path.join(os.tmpdir(), `ng-eventually-recon-${Date.now()}.ngw`); fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64")); let cleanCtx: BrowserContext | null = null; let cleanDir: string | null = null; let cleanPage: Page | null = null; try { const launched = await launchCleanProfileContext(); cleanCtx = launched.ctx; cleanDir = launched.dir; cleanPage = await newPage("the clean-profile session", cleanCtx); cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message)); cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); }); // Import the SAME wallet into the empty profile (broker-only repos), then open // the SDK page in a fresh broker session over it. await importWalletViaFile(cleanPage, ngwPath); const cleanFrame = await setupBrokerPage(cleanPage, url); await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); const cleanInfo = await sdkGet(cleanFrame, "sessionInfo"); check( "clean-profile session connected (fresh verifier, broker-only repos)", cleanInfo?.session_id !== undefined && cleanInfo?.session_id !== null, `session=${cleanInfo?.session_id}`, ); const r = await sdk(cleanFrame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker); check( "fresh clean-profile session re-reads the persisted marker (reconnection regression)", r.markerPresent === true, `rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`, ); } finally { if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close()); if (cleanCtx) await closeContext("clean-profile", cleanCtx); try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ } 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 ? ((coldSyncMs = Math.max(coldSyncMs, syncMs)), `synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`) : `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`, ); } } finally { if (rp) await closeQuietly("the reconnect page", () => rp!.close()); } }); // ── 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 { if (rp) await closeQuietly("the reconnect page", () => rp!.close()); } }); // ── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ────────────── // // Empirical pin of the implicit contract that open-repo.ts relies on: // "the 1st event emitted by doc_subscribe is a State that marks the end of // the initial broker sync — after it, presence is guaranteed and absence // is definitive." // // Three sub-contracts: // (3a) PRESENCE GUARANTEED — write a triple in session, subscribe, capture // the FIRST event; it must be a `State`, and an anchored SPARQL query // immediately after must find the triple (no second wait needed). // (3b) ABSENCE DEFINITIVE — subscribe to an empty-but-valid doc, capture // the FIRST event; it must be a `State` that reflects 0 triples AND // must NOT be followed by a late Patch within a grace window. // (3c) STATE vs TIMEOUT — the event log carries the real event-type key from // the raw AppResponse (`{ V0: { State | Patch | TabInfo | … } }`), so // we can distinguish a genuine first-State from a silent timeout. // // Every wait here is event-driven (waitForFunction on the event count) with a // 30s timeout that produces a FAIL, not a silent green. console.log("\n── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──"); // 3a — PRESENCE GUARANTEED AT FIRST STATE await step("(3a) presence guaranteed at first State", async () => { const triple = { s: "urn:e2e:state:s", p: "urn:e2e:state:p", o: "state-contract-present" }; // Write the triple first (same session, write is already committed broker-side). const doc = await sdk(frame, "stateProbeWrite", triple); // Subscribe in a fresh call and start timing. const tSubscribe = Date.now(); await sdk(frame, "stateProbeSubscribe", doc); // Wait event-driven for the FIRST event of any type — reveals the push ordering. await frame.waitForFunction( () => (window as any).__sdk.stateProbeEvents().length >= 1, { timeout: 30000 }, ); const firstAnyEventMs = Date.now() - tSubscribe; const eventsAfterAny = await sdkGet>(frame, "stateProbeEvents"); const firstAnyEvent = eventsAfterAny[0]; // 3c: first event MUST have a recognised type key — distinguishes a real push // from a synthetic timeout/parse failure. The broker emits TabInfo first, then // State (VERIFIED empirically: TabInfo at ~2-5ms, State at ~5-15ms). check( "(3c) first event has a recognised type key (not a synthetic timeout)", firstAnyEvent !== undefined && firstAnyEvent.typeKey !== "unknown" && firstAnyEvent.typeKey !== "parse-error", `first-event typeKey=${firstAnyEvent?.typeKey ?? "none"} elapsedMs=${firstAnyEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstAnyEventMs}ms)`, ); // Now wait specifically for the FIRST State event (may be the 2nd+ overall push — // the broker pushes TabInfo before State). await frame.waitForFunction( () => (window as any).__sdk.stateProbeStateCount() >= 1, { timeout: 30000 }, ); const firstStateWallMs = Date.now() - tSubscribe; const eventsAfterState = await sdkGet>(frame, "stateProbeEvents"); const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State"); // 3a-i: a State event MUST arrive (not just TabInfo). This is the sync-point // barrier — the broker delivers State after syncing up to the broker's heads. check( "(3a-i) a State event arrives (sync-point barrier confirmed)", firstStateEvent !== undefined, `stateElapsedMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`, ); // 3a-ii: AFTER the State, an anchored SPARQL query must find the triple // WITHOUT any additional wait. The State is the sync barrier. const q = await sdk<{ rows: number; found: boolean }>(frame, "stateProbeQuery", triple.s, triple.p); check( "(3a-ii) triple is present in SPARQL query immediately after first State (no extra wait)", q.found === true, `rows=${q.rows} found=${q.found} stateMs=${firstStateEvent?.elapsedMs ?? "?"}ms`, ); console.log( ` [INFO] push ordering: ${eventsAfterState.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`, ); console.log( ` [INFO] first-State latency: ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`, ); await sdk(frame, "stateProbeStop"); }); // 3b — ABSENCE DEFINITIVE AT FIRST STATE await step("(3b) absence definitive at first State (empty doc stays empty)", async () => { // Create an empty doc and subscribe atomically. await sdk(frame, "stateProbeEmptyDoc"); // Wait for the first State event (TabInfo arrives first, State second). const tSubscribe = Date.now(); await frame.waitForFunction( () => (window as any).__sdk.stateProbeStateCount() >= 1, { timeout: 30000 }, ); const firstStateWallMs = Date.now() - tSubscribe; const eventsAfterState = await sdkGet>(frame, "stateProbeEvents"); const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State"); check( "(3b-i) a State event arrives for an empty doc (sync barrier fires even for empty)", firstStateEvent !== undefined, `stateMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`, ); // Verify the doc is empty via SPARQL immediately after the State. const qEmpty = await sdk<{ rows: number; found: boolean }>( frame, "stateProbeQuery", "urn:e2e:state:s", "urn:e2e:state:p", ); check( "(3b-ii) SPARQL query immediately after first State confirms the doc is empty", qEmpty.found === false && qEmpty.rows === 0, `rows=${qEmpty.rows} found=${qEmpty.found}`, ); // Grace window: wait 5s and verify no data-bearing Patch arrives after the State. // A second State is normal (broker may re-push); only a Patch with actual data // would violate "absence is definitive". We check the SPARQL result, not event types, // because a Patch on an empty doc that stays empty is also fine. await page!.waitForTimeout(5000); const qAfterGrace = await sdk<{ rows: number; found: boolean }>( frame, "stateProbeQuery", "urn:e2e:state:s", "urn:e2e:state:p", ); const eventsAfterGrace = await sdkGet>(frame, "stateProbeEvents"); check( "(3b-iii) SPARQL still empty after 5s grace window (absence at first State is definitive)", !qAfterGrace.found && qAfterGrace.rows === 0, `foundAfterGrace=${qAfterGrace.found} events=${JSON.stringify(eventsAfterGrace.map((e) => e.typeKey))}`, ); console.log( ` [INFO] push ordering: ${eventsAfterGrace.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`, ); console.log( ` [INFO] first-State latency (empty doc): ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`, ); await sdk(frame, "stateProbeStop"); }); } finally { // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already // gone never resolves, and this `finally` is where that hang swallowed the summary. if (page) await closeQuietly("the SDK harness page", () => page!.close()); if (ctx) await closeContext("sdk-harness", ctx); closeServer(); } // ── Summary ─────────────────────────────────────────────────────────────── const passed = results.filter((r) => r.ok).length; const failed = results.length - passed; const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1); console.log( `\n══ Polyfill e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ` + `— batch ${batchMin} min, slowest cold sync ${Math.round(coldSyncMs / 1000)}s ══`, ); // A fresh wallet per batch is what should keep the cold sync flat; if it climbs from // one batch to the next, the per-batch wallet is not being discarded. assertWithinBudget(); if (failed > 0) { console.log("Failures:"); for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`); process.exit(1); } } main().catch((e) => { console.error("[e2e] fatal:", e); process.exit(1); });