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:
Sylvain Duchesne
2026-07-06 23:55:07 +02:00
parent c0498a6ebc
commit 1c475d8c9f
7 changed files with 983 additions and 1 deletions
+202
View File
@@ -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;
}