/** * 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 os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ── export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib"); const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready"); export const WALLET_NAME = "ng-eventually-e2e"; export const WALLET_PASSWORD = "ng-eventually-e2e"; const ENTRY = path.resolve(__dirname, "sdk-entry.ts"); const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js"); const LAUNCH_ARGS = [ "--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations", "--allow-insecure-localhost", "--disable-web-security", ]; function resolveChromePath(): string | undefined { const p = chromium .executablePath() .replace("chrome-headless-shell", "chrome") .replace("chromium_headless_shell", "chromium"); return p.includes("headless") ? undefined : p; } export function buildBundle(): void { fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true }); execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, { stdio: "pipe", cwd: path.resolve(__dirname, ".."), }); } export function serveHarness(): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const html = `ng-eventually sdk e2e
`; const server = http.createServer((req, res) => { if (req.url === "/sdk-entry.js") { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); } else { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); } }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { const port = (server.address() as { port: number }).port; resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); }); }); } /** * Create the dedicated lib wallet once (headless UI flow on nextgraph.eu), * persisted in PROFILE_DIR. Mirrors Festipod's ensureAuth but with the lib's own * wallet name + profile. Idempotent via the ready marker. */ export async function ensureWallet(): Promise { if (fs.existsSync(WALLET_READY_MARKER)) { console.log("[e2e] dedicated lib wallet present — skipping creation"); return; } console.log("[e2e] creating dedicated lib wallet on nextgraph.eu..."); fs.mkdirSync(PROFILE_DIR, { recursive: true }); const ctx = await chromium.launchPersistentContext(PROFILE_DIR, { headless: true, executablePath: resolveChromePath(), args: LAUNCH_ARGS, }); const page = ctx.pages()[0] || (await ctx.newPage()); page.on("pageerror", () => {}); try { await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 }); const createButton = page.getByText("Create Wallet", { exact: true }); await createButton.waitFor({ state: "visible", timeout: 15000 }); await createButton.click(); await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {}); const acceptButton = page.getByText("I accept", { exact: true }); await acceptButton.waitFor({ state: "visible", timeout: 15000 }); await acceptButton.click(); const usernameInput = page.locator("#username-input"); await usernameInput.waitFor({ state: "visible", timeout: 30000 }); await usernameInput.fill(WALLET_NAME); const passwordInput = page.locator("#password-input"); await passwordInput.waitFor({ state: "visible", timeout: 5000 }); await passwordInput.fill(WALLET_PASSWORD); const submitButton = page.getByText("create my wallet", { exact: false }); await submitButton.waitFor({ state: "visible", timeout: 5000 }); await submitButton.click(); await page.waitForURL("**/#/wallet/login", { timeout: 30000 }); await page.waitForTimeout(2000); // First login → bootstrap the verifier repos from the broker. const walletLink = page.getByText("Click here to login with your wallet"); if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) { await walletLink.click(); await page.waitForTimeout(1000); } const loginPassword = page.locator('input[type="password"]'); await loginPassword.waitFor({ state: "visible", timeout: 10000 }); await loginPassword.fill(WALLET_PASSWORD); await loginPassword.press("Enter"); await page.waitForTimeout(10000); console.log("[e2e] dedicated lib wallet created + bootstrapped"); } finally { await ctx.close(); } fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString()); } export async function launchWalletContext(): Promise { return chromium.launchPersistentContext(PROFILE_DIR, { headless: true, executablePath: resolveChromePath(), args: LAUNCH_ARGS, }); } /** * Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir). * Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: * the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so * a session over it starts with an empty `self.repos`. Caller must import the wallet * (see {@link importWalletViaFile}) before opening the SDK page. Returns the context * and the dir so the caller can clean it up. */ export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-")); const ctx = await chromium.launchPersistentContext(dir, { headless: true, executablePath: resolveChromePath(), args: LAUNCH_ARGS, }); return { ctx, dir }; } /** * Import a `.ngw` wallet FILE into the current (clean) profile via the standalone * nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After * this the profile holds the wallet — but NOT the repos' local cache — so the next * SDK session over it hits the broker-only cold-start. Adapted from the Festipod * app's `importWalletViaFile` (the proven real-broker wallet-file import). */ export async function importWalletViaFile(page: Page, ngwPath: string): Promise { await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" }); // Let the SPA render + attach the file input (uploading too early → EncryptionError). await page.waitForTimeout(3000); await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 }); await page.setInputFiles('input[type=file]', ngwPath); const passwordInput = page.locator('input[type=password]').first(); await passwordInput.waitFor({ state: "visible", timeout: 15000 }); await passwordInput.fill(WALLET_PASSWORD); await passwordInput.press("Enter"); const confirm = page.getByRole("button", { name: /Confirm/i }); if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {}); await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker } /** * Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the * broker iframe; unlock the dedicated wallet if a login is shown; return the app * iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin. */ export async function setupBrokerPage(page: Page, appUrl: string): Promise { const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`; await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" }); const loginButton = page.getByText("Login", { exact: true }); if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) { await loginButton.click(); await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {}); } const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1")); const walletLink = page.getByText("Click here to login with your wallet", { exact: false }); const loginDeadline = Date.now() + 25000; while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) { await page.waitForTimeout(500); } if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) { await walletLink.click(); await page.waitForTimeout(1000); const passwordInput = page.locator('input[type="password"]'); if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) { await passwordInput.fill(WALLET_PASSWORD); await passwordInput.press("Enter"); await page.waitForTimeout(3000); } } let appFrame: Frame | null = null; const deadline = Date.now() + 30000; while (Date.now() < deadline) { for (const f of page.frames()) { if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) { appFrame = f; break; } } if (appFrame) break; for (const iframe of await page.locator("iframe").all()) { const src = await iframe.getAttribute("src"); if (src && src.includes("127.0.0.1")) { const el = await iframe.elementHandle(); appFrame = (await el?.contentFrame()) ?? null; if (appFrame) break; } } if (appFrame) break; await page.waitForTimeout(500); } if (!appFrame) { const frames = page.frames().map((f) => f.url()); throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`); } return appFrame; }