/** * 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, type Locator } 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 broker's sign-in, as a state machine ───────────────────────────────── // // ── The defect this replaces, and why it looked like the network ───────────── // The previous version decided where it was in the flow by TIMING: a 2-second probe for // the "Login" button, a 500 ms poll loop, a 1-second settle, an 8-second window for the // password prompt, a 3-second hope after submitting. Under it sat a predicate that was // simply wrong — an "application frame" was any frame whose URL CONTAINED `127.0.0.1`. // // VERIFIED 2026-08-14 by driving the real pages: the broker's own auth page carries the // application's address in its query string, so its MAIN frame's URL is // `https://nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975` — which contains // `127.0.0.1`. The predicate therefore matched the AUTH PAGE ITSELF, from the first // instant, before any login had happened. Everything downstream then followed: the poll // loop exited at once, the wallet click and the password were SKIPPED as "already logged // in", and the function returned `page.mainFrame()` — the broker's login screen — as the // application's frame. The caller then waited its full minute for `[data-testid="who"]` // on a page that has no such element, and reported a timeout naming nothing. // // The only thing that ever saved a run was the very first branch: clicking "Login" moves // the URL to `#/wallet/login`, which carries no `o=` parameter and so no `127.0.0.1` — // after which the broken predicate happens to behave. That click was guarded by // `isVisible({ timeout: 2000 })`, and the button paints at 1.0–1.6 s (VERIFIED, three // consecutive sign-ins). A 2-second bound on a 1.0–1.6-second event is a coin toss, and // which side it lands on is decided by how loaded the machine is — which is exactly why // this read as "the broker" or "the host network", and why it hit the SECOND actor most: // it signs in while the first one's tab is busy with its own broker traffic. // // So nothing here waits for a DURATION any more. It waits for whichever screen appears, // dispatches on it, and stops when a frame is on the application's ORIGIN — an origin the // broker's pages can never be on, whatever they carry in their query string. /** One bound for the whole ceremony. Measured healthy: 2.6–3.9s; the newcomer's cold * profile pays a broker session-start on top. Two minutes is a hang, not a slow host. */ const BROKER_LOGIN_MS = 120_000; /** How often the browser re-reads the screen. Not a sleep: it is the interval of a * condition check that runs INSIDE the page, the same mechanism `isVisible` uses. */ const SCREEN_POLL_MS = 200; /** A screen answered this many times without the flow moving on is a livelock, not a * slow page — say so instead of clicking for ever. */ const SCREEN_REVISITS_ALLOWED = 3; /** A click or a fill that has not landed in 15s is not going to; the ceremony's own bound * is eight times that, so failing here leaves room to say so rather than to hang. */ const CLICK_MS = 15_000; /** Reading the text of a page for a failure message is a round-trip like any other, and * `evaluate` carries no bound of its own — a diagnosis must not become the new hang. */ const TEXT_MS = 15_000; /** * The distinct screens this flow can be on. VERIFIED 2026-08-14 against the live pages * unless noted; the upstream source is `nextgraph-rs` (`infra/ngnet/redir`, and * `engine/broker/auth` + `app/ui-common`), read but never modified. * * - `choose-broker` — the redirect page with MORE than one broker to pick from. Not * observed here (this host resolves to a single broker, which auto-selects), so it is * handled from the upstream source rather than from observation. * - `login-offered` — "We could not find a wallet on this device… Login". The entry * screen of every sign-in observed, first actor and later ones alike. * - `wallet-list` — "Select a wallet to login with", one box per wallet. * - `password` — "Enter your password". Reached by the FIRST actor only. * - `working` — a splash, "Opening your wallet…", "Wallet opened for …". Nothing to do * but wait for it to become something else. Note that SUCCESS is one of these: the * final screen never stops being `working`, which is why the app frame is watched * separately rather than inferred from the screen. * - `error` — the broker said no ("An error occurred", "Invalid request"). Terminal. */ type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list" | "password" | "working" | "error"; /** * Which screen the page is on — evaluated INSIDE the page, returning `false` while it is * still the one the caller already knows about, so it doubles as the change detector. * * Self-contained on purpose: Playwright ships this function's source into the browser, so * it may close over nothing at all. It is passed both to `waitForFunction` (wait for a * DIFFERENT screen) and to `evaluate` (read the current one) — one definition, so the * name in a failure message is always the name the machine dispatched on. * * The order of the tests is the load-bearing part: each screen is identified by the * signature that the screens BEFORE it do not have. Visibility is checked by measured * size rather than by presence, because the auth app hides its whole login UI (`#app` gets * `display:none`) instead of removing it once the wallet is open — a presence test would * keep reporting `wallet-list` on a page that has already logged in. */ function readBrokerScreen(previous: string | null): string | false { const shown = (el: Element | null): boolean => { if (el === null) return false; const box = el.getBoundingClientRect(); return box.width > 0 && box.height > 0; }; let kind: string; if (shown(document.querySelector("#password-input")) || shown(document.querySelector('input[type="password"]'))) { kind = "password"; } else if (shown(document.querySelector(".wallet-box"))) { kind = "wallet-list"; } else if (shown(document.querySelector('[role="menuitem"]'))) { kind = "choose-broker"; } else { const entry = Array.from(document.querySelectorAll("button, a")).find((el) => /^(login|anmelden)$/i.test((el.textContent ?? "").trim()), ); if (shown(entry ?? null)) { kind = "login-offered"; } else { // `innerText`, not `textContent`, and only here: it is the one test that has to // read prose rather than a selector, and only what is RENDERED counts — the hidden // login UI mentioned above would otherwise answer for a page that is not showing it. const visible = document.body === null ? "" : document.body.innerText; kind = /An error occurred|Invalid request/i.test(visible) ? "error" : "working"; } } return kind === previous ? false : kind; } /** * The application's frame: a SUB-frame whose URL is on the application's own origin. * * Both halves matter. `startsWith(origin)` rather than `includes(host)` is what stops the * broker's own pages from answering — they carry the application's address as a query * parameter, and that is the whole defect this file used to have. Excluding the MAIN frame * is the second half, and it is not redundant: a journey that lets the application hand * the page over itself calls this while the page is still ON the application, top-level, * and a main-frame match there would hand back a frame that is about to navigate away. * * Event-driven rather than polled — `frameattached`/`framenavigated` is exactly the signal, * so there is nothing to sleep between. */ interface AppFrameWatcher { /** The frame now, or null. */ found(): Frame | null; /** Resolves once one appears. The same promise every time, so racing it costs no listener. */ whenFound(): Promise; stop(): void; } function watchForAppFrame(page: Page, appOrigin: string): AppFrameWatcher { const pick = (): Frame | null => { for (const f of page.frames()) { if (f === page.mainFrame()) continue; if (f.url().startsWith(appOrigin)) return f; } return null; }; let settle: ((f: Frame) => void) | null = null; const appeared = new Promise((resolve) => { settle = resolve; }); const check = (): void => { const f = pick(); if (f !== null && settle !== null) { settle(f); settle = null; } }; page.on("frameattached", check); page.on("framenavigated", check); check(); return { found: pick, whenFound: () => appeared, stop: () => { page.off("frameattached", check); page.off("framenavigated", check); }, }; } type BrokerEvent = | { kind: "frame"; frame: Frame } | { kind: "screen"; screen: BrokerScreen } /** Nothing happened before the bound. `because` is set when the watch itself failed * (a closed browser, say) rather than simply running out of time — reporting the two * the same way is how "the screen never changed" gets blamed for a dead browser. */ | { kind: "stalled"; because: string | null }; /** * Whichever comes first: a screen that is not `previous`, or the application's frame. * * The race is not an optimisation. The successful end of this flow leaves the page on a * screen that never changes again ("Wallet opened for …", which reads as `working`), so a * wait for a screen CHANGE alone would sit there until its deadline with the frame it * wanted already attached. */ async function nextBrokerEvent( page: Page, watcher: AppFrameWatcher, previous: BrokerScreen | null, ms: number, ): Promise { const onScreen = page .waitForFunction(readBrokerScreen, previous, { timeout: ms, polling: SCREEN_POLL_MS }) .then(async (handle): Promise => { const value = await handle.jsonValue(); return typeof value === "string" ? { kind: "screen", screen: value as BrokerScreen } : { kind: "stalled", because: null }; }); // Absorbed, so the loser of the race cannot surface as an unhandled rejection minutes // after the winner has already been acted on — the same hazard `deadline.ts` documents. // A plain expiry is NOT an error worth quoting; anything else is, and is quoted. const settled = onScreen.catch((e: unknown): BrokerEvent => { const message = String((e as Error)?.message ?? e).split("\n")[0] ?? ""; const expired = (e as Error)?.name === "TimeoutError" || /Timeout .* exceeded/i.test(message); return { kind: "stalled", because: expired ? null : message }; }); const onFrame = watcher.whenFound().then((frame): BrokerEvent => ({ kind: "frame", frame })); return Promise.race([settled, onFrame]); } /** Answer a screen. Returns what it did, for the failure message, or null if there was * nothing to do but wait. A click that cannot land is reported, not thrown: the loop * sees the screen again and the revisit cap turns a stuck click into a named failure. */ async function actOnBrokerScreen(page: Page, screen: BrokerScreen): Promise { const click = async (what: string, locator: Locator): Promise => { try { await locator.first().click({ timeout: CLICK_MS }); return `clicked ${what}`; } catch (e) { return `could NOT click ${what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`; } }; switch (screen) { case "choose-broker": return click("the first broker in the list", page.locator('[role="menuitem"]')); case "login-offered": return click('the "Login" button', page.getByText("Login", { exact: true })); case "wallet-list": // The BOX, not its caption: the caption only renders for a wallet that carries a // password, and the box is the thing with `role="button"` either way. return click("this batch's wallet", page.locator(".wallet-box")); case "password": { const field = page.locator("#password-input, input[type='password']").first(); try { await field.fill(WALLET_PASSWORD, { timeout: CLICK_MS }); await field.press("Enter", { timeout: CLICK_MS }); return "filled the password and submitted it"; } catch (e) { return `could NOT submit the password: ${String((e as Error)?.message ?? e).split("\n")[0]}`; } } case "working": case "error": return null; } } /** * Why the sign-in did not get where it was going — with enough on it to skip the guessing. * * What today's version said was "SDK iframe not found after 30s" plus a list of frame URLs, * and a whole day went into attributing that to the network. So this names the screen the * machine last recognised, the screens it walked through and what it did on each, the * origin it was waiting for, every frame, and the text the page was actually showing — * which is the one thing that distinguishes a broker error page from a page that is simply * still working. */ async function brokerLoginFailure( page: Page, appOrigin: string, screen: BrokerScreen | null, trail: string[], startedAt: number, why: string, ): Promise { const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); let shown: string; try { const text = await within("the failed broker page's own text", TEXT_MS, () => page.evaluate(() => (document.body === null ? "" : document.body.innerText)), ); const compact = text.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim(); shown = compact === "" ? "(the page showed nothing at all)" : compact.slice(0, 800); } catch (e) { shown = `(could not be read: ${String((e as Error)?.message ?? e).split("\n")[0]})`; } const frames = page .frames() .map((f) => ` ${f === page.mainFrame() ? "top" : "sub"} ${f.url() === "" ? "(blank)" : f.url()}`); return new Error( `[e2e broker login] gave up after ${elapsed}s — ${why}.\n` + ` last screen it recognised: ${screen ?? "(none)"}\n` + ` it was waiting for: a sub-frame whose URL starts with ${appOrigin}\n` + ` how it got here:\n${trail.length === 0 ? " (nothing happened)" : trail.map((s) => ` ${s}`).join("\n")}\n` + ` frames on the page (${frames.length}):\n${frames.join("\n")}\n` + ` what the page was showing:\n${shown .split("\n") .map((l) => ` | ${l}`) .join("\n")}`, ); } /** * The half of {@link setupBrokerPage} that does NOT navigate: walk the broker's sign-in * from whatever screen the page is on, and return the application's frame. * * 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. * * ── The second actor is a BRANCH, not a fallthrough ────────────────────────── * VERIFIED 2026-08-14, three consecutive sign-ins in one browser context: the first is * asked for a password, the second and third are NOT. The wallet is broadcast between the * broker origin's tabs over a `BroadcastChannel` named `ng_wallet`, so by the time a * second actor reaches the wallet list, its wallet is already in `opened_wallets` and * selecting it logs straight in (`ui-common/src/routes/WalletLogin.svelte`, the * `$opened_wallets[selected]` path). `password` is therefore a screen that may simply * never appear, and the machine below neither expects nor requires it — it answers what * is on screen. The previous version treated the password as the normal case and gave the * no-password path an 8-second window to prove itself innocent. */ export async function completeBrokerLogin(page: Page, appUrl: string): Promise { const appOrigin = new URL(appUrl).origin; const startedAt = Date.now(); const deadline = startedAt + BROKER_LOGIN_MS; const trail: string[] = []; const actedOn = new Map(); let screen: BrokerScreen | null = null; const watcher = watchForAppFrame(page, appOrigin); try { for (;;) { const already = watcher.found(); if (already !== null) return already; const left = deadline - Date.now(); if (left <= 0) { throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "its overall deadline expired"); } const next = await nextBrokerEvent(page, watcher, screen, left); if (next.kind === "frame") return next.frame; if (next.kind === "stalled") { throw await brokerLoginFailure( page, appOrigin, screen, trail, startedAt, next.because !== null ? `watching the page stopped working: ${next.because}` : screen === null ? "no screen it recognises ever appeared" : `the ${screen} screen never changed and no application frame ever appeared`, ); } screen = next.screen; trail.push(`+${((Date.now() - startedAt) / 1000).toFixed(1)}s ${screen}`); if (screen === "error") { throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "the broker showed an error page"); } const seenBefore = (actedOn.get(screen) ?? 0) + 1; actedOn.set(screen, seenBefore); if (seenBefore > SCREEN_REVISITS_ALLOWED) { throw await brokerLoginFailure( page, appOrigin, screen, trail, startedAt, `the ${screen} screen came back ${seenBefore} times — the click it answers is not moving the flow on`, ); } const did = await actOnBrokerScreen(page, screen); if (did !== null) trail.push(` ${did}`); } } finally { watcher.stop(); } }