/** * The broker crossing — `nextgraph.net/redir` → the broker's auth page → the wallet list → * (sometimes) the password → nested iframes → the application's own frame. * * ── The defect this replaces, and why it looked like the network ───────────── * An earlier 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 an element that page does not have, 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 a 2-second visibility * probe, 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. 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. * * The screens themselves, their signatures and their answers are DESCRIPTION and live in * `nextgraph-ui.ts`; this file is the driving. */ import type { Frame, Locator, Page } from "playwright"; import { CONTEXT_NAVIGATION_MS, within } from "./deadline"; import { browserTrouble } from "./known-failures"; import { BROKER_SCREENS, brokerRedirectFor, type BrokerScreen, type BrokerScreenSpec, } from "./nextgraph-ui"; /** * One bound for the whole ceremony — the screens, the clicks, and the application's frame * attaching. Measured 1.3–2.8 s for an actor and 1.7 s on a cold profile's barrier passage * (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45 s ≈ 16x the slowest measured: enough that a * busy host does not manufacture a false diagnosis, little enough that the rich failure below * — the screen, the trail, the frames, the page's own text — arrives in under a minute instead * of after two. * * Exported for the same reason as `NEW_PAGE_MS`: this function's failure message is the most * informative one in the harness, and an enclosing bound set below it would replace that * message with "the round-trip timed out" and lose every fact in it. */ export const BROKER_LOGIN_MS = 45_000; /** * What {@link setupBrokerPage} costs at worst: its navigation plus the ceremony. A caller that * wants to MEASURE the round-trip should hand this to `measured` rather than invent a bound of * its own — an enclosure below this number fires before the ceremony can explain itself. */ export const BROKER_ROUND_TRIP_MS = CONTEXT_NAVIGATION_MS + BROKER_LOGIN_MS; /** 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 15 s 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; /** * Navigate through the broker's redirect to load `appUrl` in the broker iframe, unlock the * wallet if a login is shown, and return the application's frame. */ export async function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise { await page.goto(brokerRedirectFor(appUrl), { waitUntil: "domcontentloaded" }); return completeBrokerLogin(page, appUrl, walletPassword); } /** * 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. The screen inventory is therefore an ARGUMENT, not an import * — which is also what lets the description live in one file and the driving in another. 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. */ function readBrokerScreen(input: { previous: string | null; screens: readonly BrokerScreenSpec[]; }): 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 | null = null; for (const spec of input.screens) { const signature = spec.signature; if (signature.kind === "rendered") { if (signature.selectors.some((selector) => shown(document.querySelector(selector)))) { kind = spec.screen; } } else if (signature.kind === "rendered-control") { const pattern = new RegExp(signature.matches.source, signature.matches.flags); const control = Array.from(document.querySelectorAll("button, a")).find((el) => pattern.test((el.textContent ?? "").trim()), ); if (shown(control ?? null)) kind = spec.screen; } else if (signature.kind === "page-text") { // `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 // would otherwise answer for a page that is not showing it. const pattern = new RegExp(signature.matches.source, signature.matches.flags); const visible = document.body === null ? "" : document.body.innerText; if (pattern.test(visible)) kind = spec.screen; } else { kind = spec.screen; } if (kind !== null) break; } if (kind === null) return false; return kind === input.previous ? false : kind; } /** * The application's frame: a SUB-frame whose URL is on the application's own origin. * * ── Both halves of that sentence are load-bearing ──────────────────────────── * `startsWith(origin)` rather than `includes(host)` is what stops the broker's own pages from * answering. THE BROKER'S AUTH PAGE CARRIES THE APPLICATION'S ADDRESS IN ITS QUERY STRING, so * a substring match returns the LOGIN PAGE and the whole crossing then fails silently: the * wallet click and the password are skipped as "already logged in", and the caller is handed a * frame that will never render the application. That bug cost days, and it was readable in one * line the whole time. Never match a host, a port or a path here — match the origin, from the * start of the URL. * * 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, screens: BROKER_SCREENS }, { 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, as its description says to. 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 — which says far more than the click's * own timeout would. */ async function answerBrokerScreen( page: Page, spec: BrokerScreenSpec, walletPassword: string, ): 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]}`; } }; const answer = spec.answer; switch (answer.kind) { case "click": return click(answer.what, page.locator(answer.selector)); case "click-text": return click(answer.what, page.getByText(answer.text, { exact: true })); case "submit-password": { const field = page.locator(answer.selector).first(); try { await field.fill(walletPassword, { timeout: CLICK_MS }); await field.press("Enter", { timeout: CLICK_MS }); return `filled ${answer.what} and submitted it`; } catch (e) { return `could NOT submit ${answer.what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`; } } case "wait": return null; } } /** * Why the crossing did not get where it was going — with enough on it to skip the guessing. * * An earlier version said "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. * * And, when it applies, the browser's own condition FIRST: a crossing that failed because the * browser stopped answering must not be reported as a broker problem. */ 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); const trouble = await browserTrouble("crossing", page.context()); 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` + (trouble === null ? "" : ` BUT FIRST: ${trouble}\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 crossing 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, once the identity is settled — and a journey that walks a first-time * user through an access 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, its query parameters included — i.e. it would quietly substitute the suite's path * for the one under test. * * ── A password may simply never be asked for ───────────────────────────────── * The second actor is a BRANCH, not a fallthrough: the wallet is broadcast between the broker * origin's tabs, so a later actor's selection logs straight in. The machine below neither * expects nor requires the password screen — it answers what is on screen. An earlier 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, walletPassword: 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}`); const spec = BROKER_SCREENS.find((s) => s.screen === screen); if (spec === undefined) { throw await brokerLoginFailure( page, appOrigin, screen, trail, startedAt, `the page reported a screen the inventory does not describe (${screen})`, ); } if (spec.terminal === true) { 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 answerBrokerScreen(page, spec, walletPassword); if (did !== null) trail.push(` ${did}`); } } finally { watcher.stop(); } }