/** * Real-broker e2e runner for `@ng-eventually/client` — 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/client). * * 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 type { Frame, Page, BrowserContext } from "playwright"; import { buildBundle, serveHarness, ensureWallet, launchWalletContext, setupBrokerPage, } from "./broker"; 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); } async function step(name: string, fn: () => Promise): Promise { try { await fn(); } catch (e: any) { record(name, false, "threw: " + String(e?.message ?? e)); } } // A short helper: call a bridge method inside the iframe. function sdk(frame: Frame, method: string, ...args: unknown[]): Promise { return 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 frame.evaluate( ([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])), [method, args] as const, ) as Promise; } async function main(): Promise { 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(); page = await ctx.newPage(); 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)}`); // ── 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 over N docs → per-doc subjects", async () => { const r = await sdk(frame, "readUnionOverDocs", 3, false); check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`); }); await step("readUnion per-doc tolerance (bad NURI skipped)", async () => { const r = await sdk(frame, "readUnionOverDocs", 2, true); check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`); }); 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 () => { const r = await sdk(frame, "inboxPostRead", { 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"); 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"); }); 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}`); }); // ── discovery index ───────────────────────────────────────────────────── console.log("\n── discovery index ──"); await step("discovery submit → read", async () => { const ref = { doc: "did:ng:o:some-public-doc", title: "t" }; const r = await sdk(frame, "discoverySubmitRead", ref); const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref)); check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`); }); await step("discovery watchIndex fires reactively", async () => { await sdk(frame, "discoveryWatchStart"); await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 }); const base = await sdkGet(frame, "discoveryWatchState"); await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() }); await frame.waitForFunction( (b) => (window as any).__sdk.discoveryWatchState().fires > (b as number), base.fires, { timeout: 20000 }, ); const after = await sdkGet(frame, "discoveryWatchState"); check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`); await sdk(frame, "discoveryWatchStop"); }); await step("reserved @index account isolation", async () => { const r = await sdk(frame, "discoveryIndexIsolation"); check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`); }); // ── 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)}…`); }); // ── caps / read-filter (in-memory cap model) ──────────────────────────── console.log("\n── caps / read-filter (in-memory cap model) ──"); await step("read-filter: protected hidden from stranger", async () => { const r = await sdk(frame, "capsReadFilter"); const ownerSeesProt = r.ownerView.includes("protected-item"); const strangerHiddenProt = !r.strangerView.includes("protected-item"); const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item"); const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item"); check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`); }); await step("read-filter: directed grant reveals the doc", async () => { const r = await sdk(frame, "capsDirectedGrant"); check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`); }); // ── 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}`); }); } finally { try { if (page) await page.close(); } catch { /* ignore */ } try { if (ctx) await ctx.close(); } catch { /* ignore */ } closeServer(); } // ── Summary ─────────────────────────────────────────────────────────────── const passed = results.filter((r) => r.ok).length; const failed = results.length - passed; console.log(`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ══`); 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); });