1c475d8c9f
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>
203 lines
8.1 KiB
TypeScript
203 lines
8.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|