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:
@@ -3,3 +3,8 @@ dist/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
|
# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed)
|
||||||
|
packages/client/e2e/.playwright-profile-lib/
|
||||||
|
packages/client/e2e/.dist/
|
||||||
|
*.ngw
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "ng-eventually",
|
"name": "ng-eventually",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"typescript": "^5.6.0",
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually sdk e2e</title></head><body><div id="root"></div><script type="module" src="/sdk-entry.js"></script></body></html>`;
|
||||||
|
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<void> {
|
||||||
|
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<BrowserContext> {
|
||||||
|
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<Frame> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<BrokerSession>((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 <plainNuri>` 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 <plainNuri>` → 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}> <urn:e2e:anchored> "yes" }`,
|
||||||
|
doc,
|
||||||
|
);
|
||||||
|
// (B) explicit GRAPH <plainNuri> write — targets a phantom graph.
|
||||||
|
await docs.sparqlUpdate(
|
||||||
|
s.session_id,
|
||||||
|
`INSERT DATA { GRAPH <${doc}> { <${subj}> <urn:e2e:explicitGraph> "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 <plainNuri>` 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}> <urn:e2e:explicitGraph> ?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 { <urn:e2e:rm:${i}> <urn:e2e:idx> "${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 { <urn:e2e:cg> <urn:e2e:p> "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<string, { count: number; unsub: () => 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 { <urn:e2e:sub:${marker}> <urn:e2e:m> "${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<any>)].map((i) => i.v);
|
||||||
|
// as a stranger
|
||||||
|
setCurrentUser("stranger");
|
||||||
|
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].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<any>)].length;
|
||||||
|
getCaps().grantRead("did:ng:o:sharedoc", "friend");
|
||||||
|
const after = [...(libUseShape(null, null) as Iterable<any>)].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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
|
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"test:e2e": "bun run e2e/run.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user