test(client): real-broker e2e harness for the SDK (own domain, dedicated wallet)
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 <plainNuri> {…} } 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void>): Promise<void> {
|
||||
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<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
return frame.evaluate(
|
||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
) as Promise<T>;
|
||||
}
|
||||
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
// 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<T>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<any>(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<string>(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<any>(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 <plainNuri> {…} }` ANCHORED to the same doc ALSO
|
||||
// round-trips via the anchored default-graph read (explicitGraphPresent),
|
||||
// AND is reachable via an explicit `GRAPH <plainNuri>` 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 <plainNuri> 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<any>(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<any>(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<any>(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<any>(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<number>(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<number>(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<number>(frame, "subscribeCount", "h1");
|
||||
await sdk(frame, "writeTo", doc, "w2");
|
||||
await page!.waitForTimeout(3000);
|
||||
const afterUnsub = await sdkGet<number>(frame, "subscribeCount", "h1");
|
||||
check("unsubscribe stops callbacks", afterUnsub === frozen, `count stayed ${afterUnsub}`);
|
||||
});
|
||||
await step("subscribeDocs per-doc isolation", async () => {
|
||||
const { good } = await sdk<any>(frame, "subscribeDocsStart");
|
||||
await frame.waitForFunction(() => (window as any).__sdk.multiSubCounts().good >= 1, { timeout: 20000 });
|
||||
const base = await sdkGet<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<string>(frame, "identitySet", "@ident-user");
|
||||
const got = await sdkGet<string>(frame, "identityGet");
|
||||
const cleared = await sdk<string | null>(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);
|
||||
});
|
||||
Reference in New Issue
Block a user