/**
* 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();
}
}