From 1c475d8c9f7109ee1c0dcfd0fa1ba853b0b9cba1 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 6 Jul 2026 23:55:07 +0200 Subject: [PATCH] test(client): real-broker e2e harness for the SDK (own domain, dedicated wallet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polyfill had only fake-ng unit tests; real-broker behaviour (SPARQL graph round-trip, doc_subscribe marshaling, inbox/index) was only ever exercised by borrowing Festipod's harness. Add packages/client/e2e/ — a standalone Playwright harness in the SDK's own domain: a minimal SDK page (configure() + the real @ng-org/web ng, a window.__sdk bridge, zero Festipod domain) loaded in the broker iframe on the real broker, authenticating with a DEDICATED wallet (name ng-eventually-e2e, profile e2e/.playwright-profile-lib — gitignored, separate from Festipod's). Runner: bun run e2e/run.ts (script test:e2e); not mixed into bun test. Covers, against the real broker (23 checks, all green): docCreate; sparqlUpdate/ Query anchored default-graph round-trip; readUnion N-doc + per-doc tolerance + cap gate; doc_subscribe initial+write+unsub and subscribeDocs per-doc isolation; inbox post/read/watch + spoof guard; discovery submit/read/watchIndex + reserved-account isolation; store-registry idempotency + bounded listMyEntityDocs + scope resolvers; caps read-filter (in-memory, honestly scoped); IdentityStore. Finding (reported, not a failure): on @ng-org/web 0.1.2-alpha.13 an anchored INSERT DATA { GRAPH {…} } DOES round-trip (no phantom graph) — several lib comments assert the opposite; stale, to reconcile. Behaviour is unaffected (the lib writes the always-safe no-GRAPH default-graph shape). bun test still 91 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 + bun.lock | 7 + package.json | 1 + packages/client/e2e/broker.ts | 202 +++++++++++++ packages/client/e2e/run.ts | 290 +++++++++++++++++++ packages/client/e2e/sdk-entry.ts | 476 +++++++++++++++++++++++++++++++ packages/client/package.json | 3 +- 7 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 packages/client/e2e/broker.ts create mode 100644 packages/client/e2e/run.ts create mode 100644 packages/client/e2e/sdk-entry.ts diff --git a/.gitignore b/.gitignore index 5932e78..2c0aabc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ dist/ *.tsbuildinfo .DS_Store bun.lockb + +# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed) +packages/client/e2e/.playwright-profile-lib/ +packages/client/e2e/.dist/ +*.ngw diff --git a/bun.lock b/bun.lock index 52578d2..ca2c7cb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "ng-eventually", "devDependencies": { "@types/bun": "latest", + "playwright": "^1.61.1", "typescript": "^5.6.0", }, }, @@ -134,6 +135,8 @@ "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -192,6 +195,10 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/package.json b/package.json index b6c1f16..5d22070 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@types/bun": "latest", + "playwright": "^1.61.1", "typescript": "^5.6.0" } } diff --git a/packages/client/e2e/broker.ts b/packages/client/e2e/broker.ts new file mode 100644 index 0000000..6bba772 --- /dev/null +++ b/packages/client/e2e/broker.ts @@ -0,0 +1,202 @@ +/** + * Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for + * `@ng-eventually/client`, fully separate from any consumer app's profile. + * + * Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference + * real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker + * redirect via nextgraph.net, iframe handling. Here it authenticates a wallet + * created FOR THIS LIB (distinct name + distinct profile dir), and loads the + * minimal SDK page (sdk-entry.ts) inside the broker iframe. + */ + +import { chromium, type BrowserContext, type Page, type Frame } from "playwright"; +import { execSync } from "node:child_process"; +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ── +export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib"); +const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready"); +export const WALLET_NAME = "ng-eventually-e2e"; +export const WALLET_PASSWORD = "ng-eventually-e2e"; + +const ENTRY = path.resolve(__dirname, "sdk-entry.ts"); +const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js"); + +const LAUNCH_ARGS = [ + "--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations", + "--allow-insecure-localhost", + "--disable-web-security", +]; + +function resolveChromePath(): string | undefined { + const p = chromium + .executablePath() + .replace("chrome-headless-shell", "chrome") + .replace("chromium_headless_shell", "chromium"); + return p.includes("headless") ? undefined : p; +} + +export function buildBundle(): void { + fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true }); + execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, { + stdio: "pipe", + cwd: path.resolve(__dirname, ".."), + }); +} + +export function serveHarness(): Promise<{ url: string; close: () => void }> { + const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); + const html = `ng-eventually sdk e2e
`; + const server = http.createServer((req, res) => { + if (req.url === "/sdk-entry.js") { + res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); + res.end(bundle); + } else { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); + }); + }); +} + +/** + * Create the dedicated lib wallet once (headless UI flow on nextgraph.eu), + * persisted in PROFILE_DIR. Mirrors Festipod's ensureAuth but with the lib's own + * wallet name + profile. Idempotent via the ready marker. + */ +export async function ensureWallet(): Promise { + if (fs.existsSync(WALLET_READY_MARKER)) { + console.log("[e2e] dedicated lib wallet present — skipping creation"); + return; + } + console.log("[e2e] creating dedicated lib wallet on nextgraph.eu..."); + fs.mkdirSync(PROFILE_DIR, { recursive: true }); + const ctx = await chromium.launchPersistentContext(PROFILE_DIR, { + headless: true, + executablePath: resolveChromePath(), + args: LAUNCH_ARGS, + }); + const page = ctx.pages()[0] || (await ctx.newPage()); + page.on("pageerror", () => {}); + try { + await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 }); + const createButton = page.getByText("Create Wallet", { exact: true }); + await createButton.waitFor({ state: "visible", timeout: 15000 }); + await createButton.click(); + + await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {}); + const acceptButton = page.getByText("I accept", { exact: true }); + await acceptButton.waitFor({ state: "visible", timeout: 15000 }); + await acceptButton.click(); + + const usernameInput = page.locator("#username-input"); + await usernameInput.waitFor({ state: "visible", timeout: 30000 }); + await usernameInput.fill(WALLET_NAME); + const passwordInput = page.locator("#password-input"); + await passwordInput.waitFor({ state: "visible", timeout: 5000 }); + await passwordInput.fill(WALLET_PASSWORD); + + const submitButton = page.getByText("create my wallet", { exact: false }); + await submitButton.waitFor({ state: "visible", timeout: 5000 }); + await submitButton.click(); + + await page.waitForURL("**/#/wallet/login", { timeout: 30000 }); + await page.waitForTimeout(2000); + + // First login → bootstrap the verifier repos from the broker. + const walletLink = page.getByText("Click here to login with your wallet"); + if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) { + await walletLink.click(); + await page.waitForTimeout(1000); + } + const loginPassword = page.locator('input[type="password"]'); + await loginPassword.waitFor({ state: "visible", timeout: 10000 }); + await loginPassword.fill(WALLET_PASSWORD); + await loginPassword.press("Enter"); + await page.waitForTimeout(10000); + console.log("[e2e] dedicated lib wallet created + bootstrapped"); + } finally { + await ctx.close(); + } + fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString()); +} + +export async function launchWalletContext(): Promise { + return chromium.launchPersistentContext(PROFILE_DIR, { + headless: true, + executablePath: resolveChromePath(), + args: LAUNCH_ARGS, + }); +} + +/** + * Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the + * broker iframe; unlock the dedicated wallet if a login is shown; return the app + * iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin. + */ +export async function setupBrokerPage(page: Page, appUrl: string): Promise { + const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`; + await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" }); + + const loginButton = page.getByText("Login", { exact: true }); + if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await loginButton.click(); + await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {}); + } + + const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1")); + const walletLink = page.getByText("Click here to login with your wallet", { exact: false }); + const loginDeadline = Date.now() + 25000; + while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) { + await page.waitForTimeout(500); + } + + if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) { + await walletLink.click(); + await page.waitForTimeout(1000); + const passwordInput = page.locator('input[type="password"]'); + if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) { + await passwordInput.fill(WALLET_PASSWORD); + await passwordInput.press("Enter"); + await page.waitForTimeout(3000); + } + } + + let appFrame: Frame | null = null; + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + for (const f of page.frames()) { + if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) { + appFrame = f; + break; + } + } + if (appFrame) break; + for (const iframe of await page.locator("iframe").all()) { + const src = await iframe.getAttribute("src"); + if (src && src.includes("127.0.0.1")) { + const el = await iframe.elementHandle(); + appFrame = (await el?.contentFrame()) ?? null; + if (appFrame) break; + } + } + if (appFrame) break; + await page.waitForTimeout(500); + } + + if (!appFrame) { + const frames = page.frames().map((f) => f.url()); + throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`); + } + return appFrame; +} diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts new file mode 100644 index 0000000..a73c133 --- /dev/null +++ b/packages/client/e2e/run.ts @@ -0,0 +1,290 @@ +/** + * 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 anchored default-graph round-trip", async () => { + const rt = await sdk(frame, "docRoundTrip"); + // 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 what read-model / inbox / store-registry + // all rely on. + check( + "anchored default-graph write ROUND-TRIPS (the shape the lib writes)", + rt.anchoredPresent === true, + `predicates=${JSON.stringify(rt.predicates)}`, + ); + // FINDING (reported, not asserted): on this broker version, an explicit + // `INSERT DATA { GRAPH {…} }` ANCHORED to the same doc ALSO + // round-trips via the anchored default-graph read (explicitGraphPresent), + // AND is reachable via an explicit `GRAPH ` read + // (explicitViaNamedGraph). i.e. when anchored, the plain NURI resolves to + // the same repo graph — it is NOT a phantom graph here. The lib still writes + // the no-GRAPH default-graph shape (the always-safe one); this records what + // the broker actually does, so the lib's "phantom graph" comments can be + // re-checked against this broker version. + record( + "[finding] explicit GRAPH when anchored resolves to the same repo", + true, + `defaultGraphRead=${rt.explicitGraphPresent} namedGraphRead=${rt.explicitViaNamedGraph} (informational)`, + ); + }); + + // ── 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); +}); diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts new file mode 100644 index 0000000..617a153 --- /dev/null +++ b/packages/client/e2e/sdk-entry.ts @@ -0,0 +1,476 @@ +/** + * SDK e2e harness entry — the MINIMAL page loaded inside the broker iframe. + * + * It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/client`), + * configures the polyfill session injection exactly the way a consumer does + * (`configure` + `configureStoreRegistry`), waits for the real broker to hand back + * a session, then exposes `window.__sdk`: a flat bag of async methods the + * Playwright test drives. This has ZERO application domain — no Festipod shapes, + * screens, or entities. It exercises the polyfill's OWN surface against the real + * broker. + * + * Why a bridge object of async methods (not calling the lib from Node): the real + * `ng` is browser-only (WASM + iframe RPC). Every SDK call must run INSIDE the + * broker iframe; Playwright reaches in via `frame.evaluate(() => window.__sdk.x())`. + */ + +import { ng as realNg, init as realInit } from "@ng-org/web"; +import { configure, configureStoreRegistry, setCurrentUser, getCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill"; +import { + docs, + subscribeDoc, + subscribeDocs, + readModel, + inbox, + discovery, + storeRegistry, + useShape as libUseShape, + accounts, +} from "@ng-eventually/client"; + +const { IdentityStore } = accounts; + +// ── The broker session, resolved once the iframe connects ────────────────── +interface BrokerSession { + session_id: string; + private_store_id: string; + protected_store_id: string; + public_store_id: string; + [k: string]: unknown; +} + +let session: BrokerSession | null = null; +let sessionResolve!: (s: BrokerSession) => void; +const sessionReady = new Promise((r) => (sessionResolve = r)); + +// A minimal in-memory Set-like, so the lib's read-filtered `useShape` view can be +// exercised WITHOUT the real ORM (`@ng-org/orm` is not installed here, and the +// read-filter is pure — it wraps whatever Set-like the injected useShape returns). +// The injected useShape receives `(shapeType, scope)`; we ignore both and return +// the items array captured by the test through window.__sdk.caps.seedSet(). +let injectedSetItems: any[] = []; +function fakeUseShape(_shape: unknown, _scope: unknown): any { + const items = () => injectedSetItems; + return { + get size() { return items().length; }, + [Symbol.iterator]() { return items()[Symbol.iterator](); }, + forEach(cb: (v: unknown) => void) { items().forEach(cb); }, + add(item: any) { injectedSetItems.push(item); }, + }; +} + +// ── Inject the real SDK + polyfill settings (the consumer bootstrap) ──────── +configure({ + ng: realNg, + useShape: fakeUseShape, + init: realInit, +}); + +configureStoreRegistry({ + // The registry (+ subscribe/inbox/discovery/read-model) reach the session + // through this. It resolves once the broker connects. + getSession: async () => { + const s = await sessionReady; + return { + sessionId: s.session_id, + privateStoreId: s.private_store_id, + protectedStoreId: s.protected_store_id, + publicStoreId: s.public_store_id, + }; + }, + // Identity normalization used as the shim key (lowercase, strip leading `@`). + normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), +}); + +// ── Bridge marshaling note ───────────────────────────────────────────────── +// Everything crossing frame.evaluate must be structured-cloneable. NURIs/strings/ +// numbers/plain objects are fine. We never return functions or the raw `ng`. + +const state: { status: string; error?: string } = { status: "connecting" }; + +// Identity store over the iframe's localStorage (the real AccountStorage). +const identity = new IdentityStore( + typeof window !== "undefined" && window.localStorage ? window.localStorage : null, +); + +// Expose the bridge immediately (status reflects connection progress). +(window as any).__sdk = { + status: () => state.status, + error: () => state.error ?? null, + sessionInfo: () => + session + ? { + session_id: session.session_id, + private_store_id: session.private_store_id, + protected_store_id: session.protected_store_id, + public_store_id: session.public_store_id, + } + : null, + + // ── docs primitives ────────────────────────────────────────────────────── + async docCreate() { + const s = await sessionReady; + return docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + }, + async sparqlUpdate(query: string, anchor?: string) { + const s = await sessionReady; + return docs.sparqlUpdate(s.session_id, query, anchor); + }, + async sparqlQuery(query: string, anchor?: string) { + const s = await sessionReady; + return docs.sparqlQuery(s.session_id, query, undefined, anchor); + }, + /** + * The load-bearing docs test: prove the anchored DEFAULT-graph write is what + * round-trips, and an explicit `GRAPH ` write is NOT (fake-ng cannot + * see this — it needs the real broker's repo_graph_name overlay). + * - write a triple with NO GRAPH clause, anchored to the doc → default graph + * - write a DIFFERENT triple wrapped in `GRAPH ` → phantom graph + * - read back (anchored, default graph): only the anchored write is present + */ + async docRoundTrip() { + const s = await sessionReady; + const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + const subj = "urn:e2e:s"; + // (A) anchored default-graph write — the verified-working shape. + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { <${subj}> "yes" }`, + doc, + ); + // (B) explicit GRAPH write — targets a phantom graph. + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { GRAPH <${doc}> { <${subj}> "yes" } }`, + doc, + ); + // read back anchored default graph + const res: any = await docs.sparqlQuery( + s.session_id, + `SELECT ?p ?o WHERE { <${subj}> ?p ?o }`, + undefined, + doc, + ); + const rows = Array.isArray(res) ? res : res?.results?.bindings ?? []; + const preds = rows.map((r: any) => r.p?.value).filter(Boolean); + // (C) probe: does an explicit `GRAPH ` write, when the query is + // NOT anchored to the doc, land where the anchored default-graph read sees it? + // This distinguishes "explicit GRAPH is a phantom graph" from "explicit GRAPH + // resolves to the same repo when anchored". We also read the explicit-graph + // triple back via its OWN named graph to see where it actually lives. + const explicitNamed: any = await docs.sparqlQuery( + s.session_id, + `SELECT ?o WHERE { GRAPH <${doc}> { <${subj}> ?o } }`, + undefined, + doc, + ); + const enRows = Array.isArray(explicitNamed) ? explicitNamed : explicitNamed?.results?.bindings ?? []; + return { + doc, + predicates: preds, + anchoredPresent: preds.includes("urn:e2e:anchored"), + explicitGraphPresent: preds.includes("urn:e2e:explicitGraph"), + explicitViaNamedGraph: enRows.length > 0, + }; + }, + + // ── read-model ─────────────────────────────────────────────────────────── + /** + * Create N docs, write a marker triple into each (anchored default graph), then + * readUnion over them → one subject per doc. Optionally inject a bad NURI to + * prove per-doc tolerance (the bad one is skipped, the batch survives). + */ + async readUnionOverDocs(n: number, includeBad: boolean) { + const s = await sessionReady; + const docNuris: string[] = []; + for (let i = 0; i < n; i++) { + const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { "${i}" }`, + d, + ); + docNuris.push(d); + } + const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; + const subjects = await readModel.readUnion(toRead); + return { docNuris, subjectCount: subjects.length, subjects }; + }, + /** + * readUnion cap gate: create a doc, mark it protected for owner O, set the + * current user to a DIFFERENT identity, and readUnion → the doc is dropped. + */ + async readUnionCapGate() { + const s = await sessionReady; + resetCaps(); + const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + await docs.sparqlUpdate(s.session_id, `INSERT DATA { "x" }`, doc); + getCaps().open(doc, "protected", "owner-O"); + setCurrentUser("someone-else"); + const asStranger = await readModel.readUnion([doc]); + setCurrentUser("owner-O"); + const asOwner = await readModel.readUnion([doc]); + resetCaps(); + setCurrentUser(null); + return { strangerCount: asStranger.length, ownerCount: asOwner.length }; + }, + + // ── reactivity (doc_subscribe) ─────────────────────────────────────────── + // The test installs a counter; subscribeDoc pushes an initial state, then a + // push per subsequent write. We record every push and expose the count/log so + // the test can wait event-driven (waitForFunction on the counter), never timed. + _subs: {} as Record void }>, + async subscribeDocStart(handle: string) { + const s = await sessionReady; + const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + const rec = { count: 0, unsub: () => {} }; + (window as any).__sdk._subs[handle] = rec; + rec.unsub = subscribeDoc(doc, () => { + rec.count += 1; + }); + return { doc }; + }, + subscribeCount(handle: string) { + return (window as any).__sdk._subs[handle]?.count ?? -1; + }, + async writeTo(doc: string, marker: string) { + const s = await sessionReady; + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { "${marker}" }`, + doc, + ); + }, + subscribeStop(handle: string) { + const rec = (window as any).__sdk._subs[handle]; + if (rec) rec.unsub(); + }, + /** + * subscribeDocs isolation: subscribe to [goodDoc, deadDoc]. The dead doc's + * subscription fails in isolation; the good doc still fires on its write. + */ + _multiSub: { good: 0, bad: 0, unsub: () => {} }, + async subscribeDocsStart() { + const s = await sessionReady; + const good = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + const bad = "did:ng:o:absent-doc-never-created"; + const rec = { good: 0, bad: 0, unsub: () => {} }; + (window as any).__sdk._multiSub = rec; + rec.unsub = subscribeDocs([good, bad], (nuri) => { + if (nuri === good) rec.good += 1; + else rec.bad += 1; + }); + return { good, bad }; + }, + multiSubCounts() { + const r = (window as any).__sdk._multiSub; + return { good: r.good, bad: r.bad }; + }, + multiSubStop() { + (window as any).__sdk._multiSub.unsub(); + }, + + // ── inbox ──────────────────────────────────────────────────────────────── + async inboxPostRead(payloadA: unknown, payloadB: unknown) { + const s = await sessionReady; + const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + setCurrentUser("inbox-user"); + await inbox.post(target, { payload: payloadA, from: null, ts: 1000 }); + await inbox.post(target, { payload: payloadB, from: null, ts: 2000 }); + const deposits = await inbox.read(target); + setCurrentUser(null); + return { target, deposits }; + }, + // watch (doc_subscribe-based) fires when a deposit lands. + _inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" }, + async inboxWatchStart() { + const s = await sessionReady; + const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + const rec = { fires: 0, lastLen: -1, unsub: () => {}, target }; + (window as any).__sdk._inboxWatch = rec; + rec.unsub = inbox.watch(target, (deposits) => { + rec.fires += 1; + rec.lastLen = deposits.length; + }); + return { target }; + }, + async inboxWatchDeposit(payload: unknown) { + const rec = (window as any).__sdk._inboxWatch; + setCurrentUser("watcher"); + await inbox.post(rec.target, { payload, from: null }); + setCurrentUser(null); + }, + inboxWatchState() { + const r = (window as any).__sdk._inboxWatch; + return { fires: r.fires, lastLen: r.lastLen }; + }, + inboxWatchStop() { + (window as any).__sdk._inboxWatch.unsub(); + }, + // spoof guard: depositing as another principal throws. + async inboxSpoofGuard() { + const s = await sessionReady; + const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); + setCurrentUser("alice"); + let threw = false; + try { + await inbox.post(target, { payload: { x: 1 }, from: "bob" }); + } catch { + threw = true; + } + // self + anonymous both allowed + let selfOk = true, anonOk = true; + try { await inbox.post(target, { payload: { x: 2 }, from: "alice" }); } catch { selfOk = false; } + try { await inbox.post(target, { payload: { x: 3 }, from: null }); } catch { anonOk = false; } + setCurrentUser(null); + return { spoofRejected: threw, selfOk, anonOk }; + }, + + // ── discovery index ────────────────────────────────────────────────────── + async discoverySubmitRead(ref: unknown) { + setCurrentUser("publisher"); + await discovery.submitToIndex(ref); + setCurrentUser(null); + const entries = await discovery.readIndex(); + return { entries }; + }, + _discWatch: { fires: 0, lastLen: -1, unsub: () => {} }, + discoveryWatchStart() { + const rec = { fires: 0, lastLen: -1, unsub: () => {} }; + (window as any).__sdk._discWatch = rec; + rec.unsub = discovery.watchIndex((entries) => { + rec.fires += 1; + rec.lastLen = entries.length; + }); + }, + async discoverySubmit(ref: unknown) { + setCurrentUser("publisher2"); + await discovery.submitToIndex(ref); + setCurrentUser(null); + }, + discoveryWatchState() { + const r = (window as any).__sdk._discWatch; + return { fires: r.fires, lastLen: r.lastLen }; + }, + discoveryWatchStop() { + (window as any).__sdk._discWatch.unsub(); + }, + // reserved @index account isolation: a real user named "index"/"@index" resolves + // to a DIFFERENT account than the reserved index owner. + async discoveryIndexIsolation() { + const userIndex = await storeRegistry.ensureAccount("@index"); + const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT); + return { + userIndexDoc: userIndex.docPublic, + reservedDoc: reserved.docPublic, + disjoint: userIndex.docPublic !== reserved.docPublic, + }; + }, + + // ── store-registry ─────────────────────────────────────────────────────── + async ensureAccountIdempotent(id: string) { + storeRegistry.resetRegistryCache(); + const first = await storeRegistry.ensureAccount(id); + storeRegistry.resetRegistryCache(); + const second = await storeRegistry.ensureAccount(id); + return { + firstDocs: [first.docPublic, first.docProtected, first.docPrivate], + secondDocs: [second.docPublic, second.docProtected, second.docPrivate], + same: + first.docPublic === second.docPublic && + first.docProtected === second.docProtected && + first.docPrivate === second.docPrivate, + }; + }, + async entityDocsBounded(idA: string, idB: string) { + storeRegistry.resetRegistryCache(); + const dA1 = await storeRegistry.createEntityDoc(idA, "public"); + const dA2 = await storeRegistry.createEntityDoc(idA, "public"); + const dB1 = await storeRegistry.createEntityDoc(idB, "public"); + // listMyEntityDocs(A) → only A's docs (poll: the index append can lag). + let listA: string[] = []; + for (let i = 0; i < 12; i++) { + storeRegistry.resetRegistryCache(); + listA = await storeRegistry.listMyEntityDocs(idA, "public"); + if (listA.includes(dA1) && listA.includes(dA2)) break; + await new Promise((r) => setTimeout(r, 1000)); + } + return { + dA1, dA2, dB1, + listA, + hasA1: listA.includes(dA1), + hasA2: listA.includes(dA2), + leaksB: listA.includes(dB1), + }; + }, + async scopeResolvers() { + const priv = await storeRegistry.resolveScopeGraph("private"); + const prot = await storeRegistry.resolveScopeGraph("protected"); + const pub = await storeRegistry.resolveScopeGraph("public"); + return { priv, prot, pub }; + }, + + // ── caps / read-filter (in-memory cap model) ───────────────────────────── + // The read-filter over the injected useShape Set-like. Boundary note: the + // caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does + // NOT yet enforce per-doc read caps here (one shared wallet reads everything). + // We test what the SDK enforces: the in-memory read-filtered VIEW. + capsReadFilter() { + resetCaps(); + injectedSetItems = [ + { "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" }, + { "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" }, + { "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" }, + ]; + getCaps().open("did:ng:o:protdoc", "protected", "owner-O"); + getCaps().makePublic("did:ng:o:pubdoc"); + // as owner-O + setCurrentUser("owner-O"); + const ownerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); + // as a stranger + setCurrentUser("stranger"); + const strangerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); + resetCaps(); + injectedSetItems = []; + setCurrentUser(null); + return { ownerView, strangerView }; + }, + capsDirectedGrant() { + resetCaps(); + injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }]; + getCaps().open("did:ng:o:sharedoc", "protected", "owner-O"); + setCurrentUser("friend"); + const before = [...(libUseShape(null, null) as Iterable)].length; + getCaps().grantRead("did:ng:o:sharedoc", "friend"); + const after = [...(libUseShape(null, null) as Iterable)].length; + resetCaps(); + injectedSetItems = []; + setCurrentUser(null); + return { before, after }; + }, + + // ── accounts (IdentityStore) ───────────────────────────────────────────── + identitySet(id: string) { return identity.set(id); }, + identityGet() { return identity.get(); }, + identityClear() { identity.clear(); return identity.get(); }, +}; + +// ── Connect to the real broker ───────────────────────────────────────────── +// Mirrors ngSession.ts: register the init callback; the broker (this iframe is +// loaded by it) drives the connection and calls back with the session. +(async () => { + try { + await (realInit as any)( + (event: any) => { + session = event.session as BrokerSession; + state.status = "connected"; + sessionResolve(session); + }, + true, + [], + ); + } catch (e: any) { + state.status = "error"; + state.error = String(e?.message ?? e); + } +})(); diff --git a/packages/client/package.json b/packages/client/package.json index 794455b..4ba0c12 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,6 +27,7 @@ "@ng-org/alien-deepsignals": "0.1.2-alpha.11" }, "scripts": { - "test": "bun test" + "test": "bun test", + "test:e2e": "bun run e2e/run.ts" } }