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; }