/** * Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for * `@ng-eventually/polyfill`, 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 polyfill page (polyfill-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"; import type { Socket } from "node:net"; import { CONTEXT_ACTION_MS, CONTEXT_NAVIGATION_MS, browserLost, closeQuietly, within, } from "./deadline"; import { isAlive } from "./run-lock"; 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"); /** * Marks that a batch has already taken the physical user held in this profile. * * Named for the only question it is ever asked. It is WRITTEN when a wallet is created and * READ in exactly one place — `ensureWallet`, to decide "discard this profile and mint a * fresh user". It never gated anything on readiness, and the previous name (`.wallet-ready`) * said it did: it cost a wrong diagnosis on 2026-08-11, where its presence was read as * "the wallet is good to use" when it means the opposite — this user belongs to a batch * that is over. `user` rather than `wallet` because what a batch consumes is an identity; * the wallet is only its container (see the note on ensureWallet below). */ const USER_CONSUMED_MARKER = path.join(PROFILE_DIR, ".user-consumed"); export const WALLET_NAME = "ng-eventually-e2e"; export const WALLET_PASSWORD = "ng-eventually-e2e"; /** `bun build` is a local bundle; a minute is already ten times what it takes. */ const BUILD_MS = 60_000; /** Launching a browser is local too — 30s is Playwright's own default, doubled. */ const LAUNCH_MS = 60_000; /** Opening a page in a live browser is instant; a minute means the browser is not answering. */ const NEW_PAGE_MS = 60_000; /** The whole wallet export measures ~7s against the real broker; two minutes is a hang. */ const EXPORT_MS = 120_000; const ENTRY = path.resolve(__dirname, "polyfill-entry.ts"); const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-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, ".."), timeout: BUILD_MS, }); } /** * Serve `page` and `routes` on an ephemeral port, and hand back a close that CLOSES. * * `server.close()` alone stops the listener and then waits for every keep-alive connection * to drain on its own — a browser that is still attached keeps the server half-alive long * after the harness believes it gone. These suites close a server while a browser is still * pointed at it (the wallet export does exactly that), so the sockets are tracked and * destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the * suite instead of on the connection nobody hung up. */ export function serveOnEphemeralPort( handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, ): Promise<{ url: string; close: () => void }> { const server = http.createServer(handler); const open = new Set(); server.on("connection", (socket) => { open.add(socket); socket.on("close", () => open.delete(socket)); }); 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(); for (const socket of open) socket.destroy(); open.clear(); }, }); }); }); } export function serveHarness(): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const html = `ng-eventually polyfill e2e
`; return serveOnEphemeralPort((req, res) => { if (req.url === "/polyfill-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); } }); } // ── every browser this harness opens, launched and watched the same way ─────── /** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */ const closingOnPurpose = new WeakSet(); /** * Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied * and its disappearance turned into an immediate, named failure. * * The watch is the load-bearing part. VERIFIED 2026-08-11: this suite's browser can exit * mid-run — the devtools pipe between the runner and Chromium is terminated and Chromium * shuts down (exitCode=0) — and Playwright does NOT reject the calls already waiting on it. * A bounded wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, * and `context.close()` in a `finally`) waits for ever. That is how a 60-second failure * became three runs killed at 50 and 68 minutes having printed nothing. * * So the context's own `close` event is listened to, and anything it was not asked to do * is declared a loss once, loudly, for every wait at once. */ async function launchWatchedContext(label: string, dir: string): Promise { const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () => chromium.launchPersistentContext(dir, { headless: true, executablePath: resolveChromePath(), args: LAUNCH_ARGS, timeout: LAUNCH_MS, }), ); ctx.setDefaultTimeout(CONTEXT_ACTION_MS); ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS); const gone = (how: string) => { if (closingOnPurpose.has(ctx)) return; browserLost( `the ${label} browser went away mid-run — ${how}. Every wait on it is now ` + "unanswerable, so the run stops here instead of waiting on a browser that " + "no longer exists", ); }; // Both signals, and NEITHER of them covers the loss that hurts most — which is the whole // reason the deadlines above are not optional. // // VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the // failure this harness actually suffers — Chromium logging "Connection terminated while // reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its // client never learns the pipe is gone, so every call already in flight simply waits, and // every call after it waits too. That is why a browser dying used to cost an hour of // silence, and why no event-based guard can be the protection here: only a deadline can. // // They are wired anyway because they DO catch the losses they can see (a context closed by // something nobody asked), and those are free to catch immediately rather than at the end // of a bound. ctx.on("close", () => gone("its context closed and nobody asked it to")); ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped")); return ctx; } /** Close a context we own, bounded, without its `close` event being read as a loss. */ export async function closeContext(label: string, ctx: BrowserContext): Promise { closingOnPurpose.add(ctx); await closeQuietly(`the ${label} context`, () => ctx.close()); } /** Open a page under a bound: `context.newPage()` carries no timeout of its own. */ export function newPage(label: string, ctx: BrowserContext): Promise { return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage()); } /** * Create the dedicated lib wallet once (headless UI flow on nextgraph.eu), * persisted in PROFILE_DIR for the duration of the batch. * * ── One PHYSICAL user per batch, not one forever ────────────────────────── * This used to reuse a single wallet across every run, guarded by a ready marker. That * made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual * identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and * an inbox, and they all land in the SAME physical user. Nothing ever removed them. A * cold resynchronisation is O(the physical user's size) — which this library's own docs * state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single * sync step, against 250s a week earlier, and the drift was invisible because no one * measured it. * * The fresh identities are not the mistake — they are what makes a batch reproducible * (a stable inbox accumulates its past runs' deposits otherwise). The mistake was * keeping the physical user that holds them. So: a new one per batch, which also makes * the cold-sync duration comparable from one run to the next instead of being a number * that only ever grows. * * What this does NOT change: the profile stays persistent WITHIN a batch, because * CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and * the absence of an account fork across it). */ /** * Make sure no browser is still holding `dir`, and kill the one that is. * * ── Why a run has to do this ───────────────────────────────────────────────── * A run that fails or is killed leaves its Chromium ALIVE — `BrowserContext.close()` in the * teardown gives up after its bound (and a `kill -9` on the runner never gets there at all). * That orphan keeps the shared profile open, and the NEXT run then deletes the directory * under it and launches a second Chromium on the same path. Chromium's process singleton * settles that argument by having the newcomer hand over and quit — which the runner sees * as its devtools pipe dying moments after launch (VERIFIED 2026-08-11: "Connection * terminated while reading from pipe" 170 ms after ``). * * So one failure poisons every run after it, each faster than the last, and none of them is * about the thing under test. Reclaiming the profile is what stops the cascade. * * Chromium names the holder itself: `SingletonLock` is a symlink to `-`. And the * run lock has already established that no LEGITIMATE run is alive — so whatever holds this * profile is debris, and killing it is safe. */ async function reclaimProfile(dir: string): Promise { let target: string; try { target = fs.readlinkSync(path.join(dir, "SingletonLock")); } catch { return; // no lock, nothing holding it } const pid = Number(target.slice(target.lastIndexOf("-") + 1)); if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid)) return; console.warn( `[e2e] a browser from an earlier run (pid ${pid}) still holds ${path.basename(dir)} — ` + "killing it, or this run's own browser would be refused the profile and quit", ); try { process.kill(pid, "SIGKILL"); } catch { return; // gone between the check and the signal } const deadline = Date.now() + 10_000; while (isAlive(pid) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 200)); } if (isAlive(pid)) { throw new Error( `[e2e] could not reclaim ${dir}: pid ${pid} survived SIGKILL. Nothing this run does ` + "next would be measuring the code under test — stop and clear it by hand.", ); } } export async function ensureWallet(): Promise { await reclaimProfile(PROFILE_DIR); // Discard on the PROFILE's existence, not on the marker's. // // Keyed on the marker, a profile left behind by a batch that died BEFORE writing it was // silently REUSED — precisely the opposite of the per-batch rule below, and a profile // half-way through wallet creation makes the nextgraph.eu flow fail on a screen the code // does not expect ("element was detached from the DOM"). Worse, it is self-perpetuating: // the failed run writes no marker either, so every later run inherits the same wreck. // Observed 2026-08-11. The marker now only says HOW OLD the discarded user was. if (fs.existsSync(PROFILE_DIR)) { const age = fs.existsSync(USER_CONSUMED_MARKER) ? `${Math.round((Date.now() - fs.statSync(USER_CONSUMED_MARKER).mtimeMs) / 60000)} min old` : "left by a batch that did not finish"; console.log( `[e2e] discarding the previous batch's wallet (${age}) — ` + "a physical user is per-batch, see ensureWallet", ); fs.rmSync(PROFILE_DIR, { recursive: true, force: true }); } console.log("[e2e] creating this batch's wallet on nextgraph.eu..."); fs.mkdirSync(PROFILE_DIR, { recursive: true }); const ctx = await launchWatchedContext("wallet-creation", PROFILE_DIR); const page = ctx.pages()[0] || (await newPage("the wallet creation flow", ctx)); 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 closeContext("wallet-creation", ctx); } fs.writeFileSync(USER_CONSUMED_MARKER, new Date().toISOString()); } export async function launchWalletContext(label = "wallet"): Promise { return launchWatchedContext(label, PROFILE_DIR); } /** * Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched * context, without a `.user-consumed` marker and WITHOUT tearing the context down. * Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across * runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so * the cold-start (private-store repo not yet in `self.repos`) can be exercised. * * Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet, * but the context stays OPEN and is returned (with its dir) so the caller can then * open the SDK page in the SAME profile — i.e. the very first app session over a * wallet that has never run the app. Caller cleans up ctx + dir. */ export async function createFreshWalletContext(): Promise<{ ctx: BrowserContext; dir: string; name: string; }> { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-")); const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); const ctx = await launchWatchedContext("fresh-wallet", dir); const page = ctx.pages()[0] || (await newPage("the fresh wallet creation flow", ctx)); page.on("pageerror", () => {}); 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(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 (this is what a // brand-new wallet does on its very first unlock). 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); await page.close().catch(() => {}); return { ctx, dir, name }; } /** * 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 launchWatchedContext("clean-profile", dir); return { ctx, dir }; } /** * Materialize THIS batch's wallet as a `.ngw` file at `ngwPath`, and return its size. * * A wallet's bytes exist only inside the broker iframe — `ng.wallet_get_file()` is an RPC * to the wallet the broker holds — so nothing in Node can produce one. The harness page * already exposes the call (`polyfill-entry.ts`, `exportWalletFile`), and `run.ts` uses it * for the clean-profile cold-start; this wraps the same round-trip for callers that hold * no harness frame of their own. * * Why the APPLICATIVE suite needs it: the access gate HANDS A WALLET FILE OUT. Serving a * placeholder there would make the download step a decoration — the file has to be a real * wallet, or importing it cannot let anybody in. */ export async function exportWalletNgw(ctx: BrowserContext, ngwPath: string): Promise { buildBundle(); const { url, close } = await serveHarness(); const page = await newPage("the wallet export", ctx); page.on("pageerror", () => {}); try { const frame = await setupBrokerPage(page, url); await frame.waitForFunction( () => (window as unknown as { __sdk?: unknown }).__sdk !== undefined, { timeout: 30000 }, ); await frame.waitForFunction( () => (window as unknown as { __sdk: { status(): string } }).__sdk.status() === "connected", { timeout: 60000 }, ); // `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one // of the two ways this harness used to hang for ever. The whole export measures ~7s. const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () => frame.evaluate(() => ( window as unknown as { __sdk: { exportWalletFile(): Promise<{ walletName: string; b64: string; len: number }> }; } ).__sdk.exportWalletFile(), ), ); fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64")); return exported.len; } finally { await closeQuietly("the wallet export page", () => page.close()); close(); } } /** * 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). * * The password is a PARAMETER, defaulting to the batch wallet's. The applicative suite * reads it off the access gate's own screen and passes it here — which is the only way a * test can tell that what the barrier DISPLAYS is what actually opens the file. Hard-coding * it here would make that step untestable: the import would succeed on a barrier showing * anything at all, including nothing. */ export async function importWalletViaFile( page: Page, ngwPath: string, password: string = WALLET_PASSWORD, ): 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(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" }); return completeBrokerLogin(page, appUrl); } /** * The half of {@link setupBrokerPage} that does NOT navigate: unlock if a login is shown, * then wait for the application's iframe and return it. * * Split out because there are two ways to arrive at the broker, and only one of them is * the suite's. A test signs the round-trip off itself (`setupBrokerPage`); an APPLICATION * hands the page over on its own, inside `init()`, once the identity is settled — and a * journey that walks a first-time user through the barrier has to let it, because that * hand-over IS what it is checking. Calling `setupBrokerPage` there would re-navigate and * throw away the URL the application had just built, `?ng-id=` included — i.e. it would * quietly substitute the suite's path for the one under test. */ export async function completeBrokerLogin(page: Page, appUrl: string): Promise { 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; }