From 7062364569f8ceb6e3d802fe96c68cb8ee46d64f Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 14 Aug 2026 10:56:49 +0200 Subject: [PATCH] fix(e2e): le harnais reconnaissait la page du broker comme l'application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le prédicat qui cherchait l'iframe applicative était une correspondance de sous-chaîne : f.url().includes("127.0.0.1"). Or la page d'authentification du broker porte l'adresse de l'application DANS SA PROPRE requête — nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975. Elle correspondait donc dès le premier instant. Conséquence en chaîne : la boucle d'attente sortait immédiatement, le clic sur le portefeuille et la saisie du mot de passe étaient sautés comme « déjà connecté », et la frame principale du broker était rendue à l'appelant comme si c'était l'application. Celui-ci attendait alors une minute un élément qui n'existe pas sur cette page. Ce qui sauvait une exécution était le clic sur « Login », qui change l'URL et lui retire le paramètre — gardé par une sonde de 2 s sur un bouton mesuré à 1,0–1,6 s d'affichage. Ce tirage au sort était toute l'intermittence, et il expliquait l'asymétrie : le premier acteur fait la cérémonie du mot de passe, les suivants non, le portefeuille étant déjà ouvert et diffusé entre les onglets du broker par BroadcastChannel — vérifié, 3,4 s contre 1,4 s. Le harnais attend désormais des ÉTATS, plus des durées : il énumère les écrans possibles, attend celui qui se présente, et aiguille — sous une seule échéance. Plus aucun waitForTimeout dans ce chemin. Le chemin sans mot de passe est une branche attendue de plein droit. Et un échec nomme maintenant le dernier écran reconnu, l'origine attendue, la trace horodatée des écrans traversés, les URL de toutes les frames et le texte visible. Les échecs d'aujourd'hui ne disaient qu'une chose : qu'une chose n'était pas apparue. C'est ce silence qui a coûté la journée en conjectures. J'avais attribué tout ça au broker. C'était faux, et c'était lisible dans le code. --- .project/concepts/e2e-harness/_debt.md | 7 + packages/polyfill/e2e/broker.ts | 406 ++++++++++++++++++++++--- 2 files changed, 364 insertions(+), 49 deletions(-) create mode 100644 .project/concepts/e2e-harness/_debt.md 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; }