diff --git a/.project/concepts/e2e-harness/_debt.md b/.project/concepts/e2e-harness/_debt.md
new file mode 100644
index 0000000..61485b6
--- /dev/null
+++ b/.project/concepts/e2e-harness/_debt.md
@@ -0,0 +1,7 @@
+# Doc-debt — e2e-harness
+
+> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
+> One block = one "big change": `why` + `files` + `verify` (leaves to review).
+
+## Raw markers (consolidate into blocks, then delete)
+- TOUCHED packages/polyfill/e2e/broker.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
diff --git a/packages/polyfill/e2e/broker.ts b/packages/polyfill/e2e/broker.ts
index 555a118..b488a65 100644
--- a/packages/polyfill/e2e/broker.ts
+++ b/packages/polyfill/e2e/broker.ts
@@ -9,7 +9,7 @@
* minimal polyfill page (polyfill-entry.ts) inside the broker iframe.
*/
-import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
+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";
@@ -507,9 +507,298 @@ export async function setupBrokerPage(page: Page, appUrl: string): Promise {
+ 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
@@ -518,57 +807,76 @@ export async function setupBrokerPage(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 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 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);
- }
+ const watcher = watchForAppFrame(page, appOrigin);
+ try {
+ for (;;) {
+ const already = watcher.found();
+ if (already !== null) return already;
- 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;
+ const left = deadline - Date.now();
+ if (left <= 0) {
+ throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "its overall deadline expired");
}
- }
- 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)}`);
+ 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();
}
- return appFrame;
}