fix(e2e): le harnais reconnaissait la page du broker comme l'application
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.
This commit is contained in:
@@ -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)
|
||||
+355
-47
@@ -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<Frame
|
||||
return completeBrokerLogin(page, appUrl);
|
||||
}
|
||||
|
||||
// ── the broker's sign-in, as a state machine ─────────────────────────────────
|
||||
//
|
||||
// ── The defect this replaces, and why it looked like the network ─────────────
|
||||
// The previous 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 `[data-testid="who"]`
|
||||
// on a page that has no such element, 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
|
||||
// `isVisible({ timeout: 2000 })`, 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 any more. 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.
|
||||
|
||||
/** One bound for the whole ceremony. Measured healthy: 2.6–3.9s; the newcomer's cold
|
||||
* profile pays a broker session-start on top. Two minutes is a hang, not a slow host. */
|
||||
const BROKER_LOGIN_MS = 120_000;
|
||||
/** 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 15s 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;
|
||||
|
||||
/**
|
||||
* The half of {@link setupBrokerPage} that does NOT navigate: unlock if a login is shown,
|
||||
* then wait for the application's iframe and return it.
|
||||
* The distinct screens this flow can be on. VERIFIED 2026-08-14 against the live pages
|
||||
* unless noted; the upstream source is `nextgraph-rs` (`infra/ngnet/redir`, and
|
||||
* `engine/broker/auth` + `app/ui-common`), read but never modified.
|
||||
*
|
||||
* - `choose-broker` — the redirect page with MORE than one broker to pick from. Not
|
||||
* observed here (this host resolves to a single broker, which auto-selects), so it is
|
||||
* handled from the upstream source rather than from observation.
|
||||
* - `login-offered` — "We could not find a wallet on this device… Login". The entry
|
||||
* screen of every sign-in observed, first actor and later ones alike.
|
||||
* - `wallet-list` — "Select a wallet to login with", one box per wallet.
|
||||
* - `password` — "Enter your password". Reached by the FIRST actor only.
|
||||
* - `working` — a splash, "Opening your wallet…", "Wallet opened for …". Nothing to do
|
||||
* but wait for it to become something else. Note that SUCCESS is one of these: the
|
||||
* final screen never stops being `working`, which is why the app frame is watched
|
||||
* separately rather than inferred from the screen.
|
||||
* - `error` — the broker said no ("An error occurred", "Invalid request"). Terminal.
|
||||
*/
|
||||
type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list" | "password" | "working" | "error";
|
||||
|
||||
/**
|
||||
* 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. 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.
|
||||
*
|
||||
* The order of the tests is the load-bearing part: each screen is identified by the
|
||||
* signature that the screens BEFORE it do not have. Visibility is checked by measured
|
||||
* size rather than by presence, because the auth app hides its whole login UI (`#app` gets
|
||||
* `display:none`) instead of removing it once the wallet is open — a presence test would
|
||||
* keep reporting `wallet-list` on a page that has already logged in.
|
||||
*/
|
||||
function readBrokerScreen(previous: string | null): 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;
|
||||
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<Frame>;
|
||||
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<Frame>((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<BrokerEvent> {
|
||||
const onScreen = page
|
||||
.waitForFunction(readBrokerScreen, previous, { timeout: ms, polling: SCREEN_POLL_MS })
|
||||
.then(async (handle): Promise<BrokerEvent> => {
|
||||
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<string | null> {
|
||||
const click = async (what: string, locator: Locator): Promise<string> => {
|
||||
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<Error> {
|
||||
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<Frame
|
||||
* hand-over IS what it is checking. Calling `setupBrokerPage` there would re-navigate and
|
||||
* throw away the URL the application had just built, `?ng-id=` included — i.e. it would
|
||||
* quietly substitute the suite's path for the one under test.
|
||||
*
|
||||
* ── The second actor is a BRANCH, not a fallthrough ──────────────────────────
|
||||
* VERIFIED 2026-08-14, three consecutive sign-ins in one browser context: the first is
|
||||
* asked for a password, the second and third are NOT. The wallet is broadcast between the
|
||||
* broker origin's tabs over a `BroadcastChannel` named `ng_wallet`, so by the time a
|
||||
* second actor reaches the wallet list, its wallet is already in `opened_wallets` and
|
||||
* selecting it logs straight in (`ui-common/src/routes/WalletLogin.svelte`, the
|
||||
* `$opened_wallets[selected]` path). `password` is therefore a screen that may simply
|
||||
* never appear, and the machine below neither expects nor requires it — it answers what
|
||||
* is on screen. The previous 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): Promise<Frame> {
|
||||
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<BrokerScreen, number>();
|
||||
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 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 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`,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
return appFrame;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user