ed0f872f5d
Trois choses, dont une qui explique pourquoi aucun diagnostic n'aboutissait. SIGN_IN_MS valait 180 s alors que la somme de ses propres étapes en faisait 270. La borne englobante se déclenchait donc TOUJOURS avant celle de l'étape en cause, et ne pouvait dire qu'une chose : « bob-… to sign in ». Le message était structurellement condamné à ne rien apprendre — on a cherché des jours une cause que le harnais s'interdisait de nommer. Les bornes englobantes sont maintenant des sommes calculées de leurs étapes. Les parcours sont isolés. La suite déclare ses 7 parcours et leurs 27 vérifications AVANT tout lancement de navigateur, et rend donc toujours 34 lignes — y compris quand le montage meurt, où les parcours non exécutés sont rapportés comme tels. Auparavant le total valait 24, 26 ou 27 selon ce qui mourait : deux exécutions ne mesuraient même pas la même chose. Un échec est contenu, pas absorbé — il reste compté. Et chaque borne est dimensionnée sur une durée MESURÉE, inscrite à côté d'elle dans le code. Le premier rendu d'un acteur prend 4,9 à 7,4 s et vaut 45 s ; la traversée du broker 1,3 à 2,8 s et vaut 75 s. Un nombre nu n'apprend rien et pourrit en silence. Au passage : un walletPage.close() n'avait aucune borne du tout.
912 lines
43 KiB
TypeScript
912 lines
43 KiB
TypeScript
/**
|
||
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
|
||
* `@ng-eventually/polyfill`, fully separate from any consumer app's profile.
|
||
*
|
||
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
|
||
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
|
||
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
|
||
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
|
||
* minimal polyfill page (polyfill-entry.ts) inside the broker iframe.
|
||
*/
|
||
|
||
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";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import type { Socket } from "node:net";
|
||
import {
|
||
CONTEXT_ACTION_MS,
|
||
CONTEXT_NAVIGATION_MS,
|
||
browserLost,
|
||
closeQuietly,
|
||
within,
|
||
} from "./deadline";
|
||
import { isAlive } from "./run-lock";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
|
||
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
|
||
/**
|
||
* Marks that a batch has already taken the physical user held in this profile.
|
||
*
|
||
* Named for the only question it is ever asked. It is WRITTEN when a wallet is created and
|
||
* READ in exactly one place — `ensureWallet`, to decide "discard this profile and mint a
|
||
* fresh user". It never gated anything on readiness, and the previous name (`.wallet-ready`)
|
||
* said it did: it cost a wrong diagnosis on 2026-08-11, where its presence was read as
|
||
* "the wallet is good to use" when it means the opposite — this user belongs to a batch
|
||
* that is over. `user` rather than `wallet` because what a batch consumes is an identity;
|
||
* the wallet is only its container (see the note on ensureWallet below).
|
||
*/
|
||
const USER_CONSUMED_MARKER = path.join(PROFILE_DIR, ".user-consumed");
|
||
export const WALLET_NAME = "ng-eventually-e2e";
|
||
export const WALLET_PASSWORD = "ng-eventually-e2e";
|
||
|
||
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
|
||
const BUILD_MS = 60_000;
|
||
/** Launching a browser is local too — 30s is Playwright's own default, doubled. */
|
||
const LAUNCH_MS = 60_000;
|
||
/**
|
||
* Opening a page in a live browser is instant — measured 0.0–0.1s over a batch. Bounded at
|
||
* 10s, which is a hundred times the measurement and still fails while a reader is watching.
|
||
* Exported because the applicative suite has to know it: a caller that wraps `newPage` in a
|
||
* TIGHTER bound of its own would fire first and report its own name instead of this one.
|
||
*/
|
||
export const NEW_PAGE_MS = 10_000;
|
||
/**
|
||
* The whole wallet export measures ~7s against the real broker. Bounded at 60s ≈ 8x.
|
||
*
|
||
* Was two minutes, and that cost the batch of 2026-08-16 twice over: the export hung, and the
|
||
* suite spent two full minutes reaching a verdict it could have reached in one — before dying
|
||
* without a summary, because this runs in the SETUP, ahead of every journey.
|
||
*/
|
||
const EXPORT_MS = 60_000;
|
||
|
||
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
|
||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
|
||
|
||
const LAUNCH_ARGS = [
|
||
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
||
"--allow-insecure-localhost",
|
||
"--disable-web-security",
|
||
];
|
||
|
||
function resolveChromePath(): string | undefined {
|
||
const p = chromium
|
||
.executablePath()
|
||
.replace("chrome-headless-shell", "chrome")
|
||
.replace("chromium_headless_shell", "chromium");
|
||
return p.includes("headless") ? undefined : p;
|
||
}
|
||
|
||
export function buildBundle(): void {
|
||
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||
stdio: "pipe",
|
||
cwd: path.resolve(__dirname, ".."),
|
||
timeout: BUILD_MS,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Serve `page` and `routes` on an ephemeral port, and hand back a close that CLOSES.
|
||
*
|
||
* `server.close()` alone stops the listener and then waits for every keep-alive connection
|
||
* to drain on its own — a browser that is still attached keeps the server half-alive long
|
||
* after the harness believes it gone. These suites close a server while a browser is still
|
||
* pointed at it (the wallet export does exactly that), so the sockets are tracked and
|
||
* destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the
|
||
* suite instead of on the connection nobody hung up.
|
||
*/
|
||
export function serveOnEphemeralPort(
|
||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||
): Promise<{ url: string; close: () => void }> {
|
||
const server = http.createServer(handler);
|
||
const open = new Set<Socket>();
|
||
server.on("connection", (socket) => {
|
||
open.add(socket);
|
||
socket.on("close", () => open.delete(socket));
|
||
});
|
||
return new Promise((resolve) => {
|
||
server.listen(0, "127.0.0.1", () => {
|
||
const port = (server.address() as { port: number }).port;
|
||
resolve({
|
||
url: `http://127.0.0.1:${port}`,
|
||
close: () => {
|
||
server.close();
|
||
for (const socket of open) socket.destroy();
|
||
open.clear();
|
||
},
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
export function serveHarness(): Promise<{ url: string; close: () => void }> {
|
||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
|
||
return serveOnEphemeralPort((req, res) => {
|
||
if (req.url === "/polyfill-entry.js") {
|
||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||
res.end(bundle);
|
||
} else {
|
||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||
res.end(html);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── every browser this harness opens, launched and watched the same way ───────
|
||
|
||
/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */
|
||
const closingOnPurpose = new WeakSet<BrowserContext>();
|
||
|
||
/**
|
||
* Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied
|
||
* and its disappearance turned into an immediate, named failure.
|
||
*
|
||
* The watch is the load-bearing part. VERIFIED 2026-08-11: this suite's browser can exit
|
||
* mid-run — the devtools pipe between the runner and Chromium is terminated and Chromium
|
||
* shuts down (exitCode=0) — and Playwright does NOT reject the calls already waiting on it.
|
||
* A bounded wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`,
|
||
* and `context.close()` in a `finally`) waits for ever. That is how a 60-second failure
|
||
* became three runs killed at 50 and 68 minutes having printed nothing.
|
||
*
|
||
* So the context's own `close` event is listened to, and anything it was not asked to do
|
||
* is declared a loss once, loudly, for every wait at once.
|
||
*/
|
||
async function launchWatchedContext(label: string, dir: string): Promise<BrowserContext> {
|
||
const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () =>
|
||
chromium.launchPersistentContext(dir, {
|
||
headless: true,
|
||
executablePath: resolveChromePath(),
|
||
args: LAUNCH_ARGS,
|
||
timeout: LAUNCH_MS,
|
||
}),
|
||
);
|
||
ctx.setDefaultTimeout(CONTEXT_ACTION_MS);
|
||
ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS);
|
||
const gone = (how: string) => {
|
||
if (closingOnPurpose.has(ctx)) return;
|
||
browserLost(
|
||
`the ${label} browser went away mid-run — ${how}. Every wait on it is now ` +
|
||
"unanswerable, so the run stops here instead of waiting on a browser that " +
|
||
"no longer exists",
|
||
);
|
||
};
|
||
// Both signals, and NEITHER of them covers the loss that hurts most — which is the whole
|
||
// reason the deadlines above are not optional.
|
||
//
|
||
// VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the
|
||
// failure this harness actually suffers — Chromium logging "Connection terminated while
|
||
// reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its
|
||
// client never learns the pipe is gone, so every call already in flight simply waits, and
|
||
// every call after it waits too. That is why a browser dying used to cost an hour of
|
||
// silence, and why no event-based guard can be the protection here: only a deadline can.
|
||
//
|
||
// They are wired anyway because they DO catch the losses they can see (a context closed by
|
||
// something nobody asked), and those are free to catch immediately rather than at the end
|
||
// of a bound.
|
||
ctx.on("close", () => gone("its context closed and nobody asked it to"));
|
||
ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped"));
|
||
return ctx;
|
||
}
|
||
|
||
/** Close a context we own, bounded, without its `close` event being read as a loss. */
|
||
export async function closeContext(label: string, ctx: BrowserContext): Promise<void> {
|
||
closingOnPurpose.add(ctx);
|
||
await closeQuietly(`the ${label} context`, () => ctx.close());
|
||
}
|
||
|
||
/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */
|
||
export function newPage(label: string, ctx: BrowserContext): Promise<Page> {
|
||
return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage());
|
||
}
|
||
|
||
/**
|
||
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
|
||
* persisted in PROFILE_DIR for the duration of the batch.
|
||
*
|
||
* ── One PHYSICAL user per batch, not one forever ──────────────────────────
|
||
* This used to reuse a single wallet across every run, guarded by a ready marker. That
|
||
* made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual
|
||
* identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and
|
||
* an inbox, and they all land in the SAME physical user. Nothing ever removed them. A
|
||
* cold resynchronisation is O(the physical user's size) — which this library's own docs
|
||
* state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single
|
||
* sync step, against 250s a week earlier, and the drift was invisible because no one
|
||
* measured it.
|
||
*
|
||
* The fresh identities are not the mistake — they are what makes a batch reproducible
|
||
* (a stable inbox accumulates its past runs' deposits otherwise). The mistake was
|
||
* keeping the physical user that holds them. So: a new one per batch, which also makes
|
||
* the cold-sync duration comparable from one run to the next instead of being a number
|
||
* that only ever grows.
|
||
*
|
||
* What this does NOT change: the profile stays persistent WITHIN a batch, because
|
||
* CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and
|
||
* the absence of an account fork across it).
|
||
*/
|
||
/**
|
||
* Make sure no browser is still holding `dir`, and kill the one that is.
|
||
*
|
||
* ── Why a run has to do this ─────────────────────────────────────────────────
|
||
* A run that fails or is killed leaves its Chromium ALIVE — `BrowserContext.close()` in the
|
||
* teardown gives up after its bound (and a `kill -9` on the runner never gets there at all).
|
||
* That orphan keeps the shared profile open, and the NEXT run then deletes the directory
|
||
* under it and launches a second Chromium on the same path. Chromium's process singleton
|
||
* settles that argument by having the newcomer hand over and quit — which the runner sees
|
||
* as its devtools pipe dying moments after launch (VERIFIED 2026-08-11: "Connection
|
||
* terminated while reading from pipe" 170 ms after `<launched>`).
|
||
*
|
||
* So one failure poisons every run after it, each faster than the last, and none of them is
|
||
* about the thing under test. Reclaiming the profile is what stops the cascade.
|
||
*
|
||
* Chromium names the holder itself: `SingletonLock` is a symlink to `<host>-<pid>`. And the
|
||
* run lock has already established that no LEGITIMATE run is alive — so whatever holds this
|
||
* profile is debris, and killing it is safe.
|
||
*/
|
||
async function reclaimProfile(dir: string): Promise<void> {
|
||
let target: string;
|
||
try {
|
||
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
|
||
} catch {
|
||
return; // no lock, nothing holding it
|
||
}
|
||
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
|
||
if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid)) return;
|
||
|
||
console.warn(
|
||
`[e2e] a browser from an earlier run (pid ${pid}) still holds ${path.basename(dir)} — ` +
|
||
"killing it, or this run's own browser would be refused the profile and quit",
|
||
);
|
||
try {
|
||
process.kill(pid, "SIGKILL");
|
||
} catch {
|
||
return; // gone between the check and the signal
|
||
}
|
||
const deadline = Date.now() + 10_000;
|
||
while (isAlive(pid) && Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
}
|
||
if (isAlive(pid)) {
|
||
throw new Error(
|
||
`[e2e] could not reclaim ${dir}: pid ${pid} survived SIGKILL. Nothing this run does ` +
|
||
"next would be measuring the code under test — stop and clear it by hand.",
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function ensureWallet(): Promise<void> {
|
||
await reclaimProfile(PROFILE_DIR);
|
||
// Discard on the PROFILE's existence, not on the marker's.
|
||
//
|
||
// Keyed on the marker, a profile left behind by a batch that died BEFORE writing it was
|
||
// silently REUSED — precisely the opposite of the per-batch rule below, and a profile
|
||
// half-way through wallet creation makes the nextgraph.eu flow fail on a screen the code
|
||
// does not expect ("element was detached from the DOM"). Worse, it is self-perpetuating:
|
||
// the failed run writes no marker either, so every later run inherits the same wreck.
|
||
// Observed 2026-08-11. The marker now only says HOW OLD the discarded user was.
|
||
if (fs.existsSync(PROFILE_DIR)) {
|
||
const age = fs.existsSync(USER_CONSUMED_MARKER)
|
||
? `${Math.round((Date.now() - fs.statSync(USER_CONSUMED_MARKER).mtimeMs) / 60000)} min old`
|
||
: "left by a batch that did not finish";
|
||
console.log(
|
||
`[e2e] discarding the previous batch's wallet (${age}) — ` +
|
||
"a physical user is per-batch, see ensureWallet",
|
||
);
|
||
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
||
}
|
||
console.log("[e2e] creating this batch's wallet on nextgraph.eu...");
|
||
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||
const ctx = await launchWatchedContext("wallet-creation", PROFILE_DIR);
|
||
const page = ctx.pages()[0] || (await newPage("the wallet creation flow", ctx));
|
||
page.on("pageerror", () => {});
|
||
try {
|
||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||
const createButton = page.getByText("Create Wallet", { exact: true });
|
||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||
await createButton.click();
|
||
|
||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||
const acceptButton = page.getByText("I accept", { exact: true });
|
||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||
await acceptButton.click();
|
||
|
||
const usernameInput = page.locator("#username-input");
|
||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||
await usernameInput.fill(WALLET_NAME);
|
||
const passwordInput = page.locator("#password-input");
|
||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||
await passwordInput.fill(WALLET_PASSWORD);
|
||
|
||
const submitButton = page.getByText("create my wallet", { exact: false });
|
||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||
await submitButton.click();
|
||
|
||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// First login → bootstrap the verifier repos from the broker.
|
||
const walletLink = page.getByText("Click here to login with your wallet");
|
||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||
await walletLink.click();
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
const loginPassword = page.locator('input[type="password"]');
|
||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||
await loginPassword.fill(WALLET_PASSWORD);
|
||
await loginPassword.press("Enter");
|
||
await page.waitForTimeout(10000);
|
||
console.log("[e2e] dedicated lib wallet created + bootstrapped");
|
||
} finally {
|
||
await closeContext("wallet-creation", ctx);
|
||
}
|
||
fs.writeFileSync(USER_CONSUMED_MARKER, new Date().toISOString());
|
||
}
|
||
|
||
export async function launchWalletContext(label = "wallet"): Promise<BrowserContext> {
|
||
return launchWatchedContext(label, PROFILE_DIR);
|
||
}
|
||
|
||
/**
|
||
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
||
* context, without a `.user-consumed` marker and WITHOUT tearing the context down.
|
||
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
||
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
||
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
||
*
|
||
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
||
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
||
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
||
* wallet that has never run the app. Caller cleans up ctx + dir.
|
||
*/
|
||
export async function createFreshWalletContext(): Promise<{
|
||
ctx: BrowserContext;
|
||
dir: string;
|
||
name: string;
|
||
}> {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
||
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
const ctx = await launchWatchedContext("fresh-wallet", dir);
|
||
const page = ctx.pages()[0] || (await newPage("the fresh wallet creation flow", ctx));
|
||
page.on("pageerror", () => {});
|
||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||
const createButton = page.getByText("Create Wallet", { exact: true });
|
||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||
await createButton.click();
|
||
|
||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||
const acceptButton = page.getByText("I accept", { exact: true });
|
||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||
await acceptButton.click();
|
||
|
||
const usernameInput = page.locator("#username-input");
|
||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||
await usernameInput.fill(name);
|
||
const passwordInput = page.locator("#password-input");
|
||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||
await passwordInput.fill(WALLET_PASSWORD);
|
||
|
||
const submitButton = page.getByText("create my wallet", { exact: false });
|
||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||
await submitButton.click();
|
||
|
||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// First login → bootstrap the verifier repos from the broker (this is what a
|
||
// brand-new wallet does on its very first unlock).
|
||
const walletLink = page.getByText("Click here to login with your wallet");
|
||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||
await walletLink.click();
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
const loginPassword = page.locator('input[type="password"]');
|
||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||
await loginPassword.fill(WALLET_PASSWORD);
|
||
await loginPassword.press("Enter");
|
||
await page.waitForTimeout(10000);
|
||
await page.close().catch(() => {});
|
||
return { ctx, dir, name };
|
||
}
|
||
|
||
/**
|
||
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
||
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
|
||
* a session over it starts with an empty `self.repos`. Caller must import the wallet
|
||
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
|
||
* and the dir so the caller can clean it up.
|
||
*/
|
||
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
|
||
const ctx = await launchWatchedContext("clean-profile", dir);
|
||
return { ctx, dir };
|
||
}
|
||
|
||
/**
|
||
* Materialize THIS batch's wallet as a `.ngw` file at `ngwPath`, and return its size.
|
||
*
|
||
* A wallet's bytes exist only inside the broker iframe — `ng.wallet_get_file()` is an RPC
|
||
* to the wallet the broker holds — so nothing in Node can produce one. The harness page
|
||
* already exposes the call (`polyfill-entry.ts`, `exportWalletFile`), and `run.ts` uses it
|
||
* for the clean-profile cold-start; this wraps the same round-trip for callers that hold
|
||
* no harness frame of their own.
|
||
*
|
||
* Why the APPLICATIVE suite needs it: the access gate HANDS A WALLET FILE OUT. Serving a
|
||
* placeholder there would make the download step a decoration — the file has to be a real
|
||
* wallet, or importing it cannot let anybody in.
|
||
*/
|
||
export async function exportWalletNgw(ctx: BrowserContext, ngwPath: string): Promise<number> {
|
||
buildBundle();
|
||
const { url, close } = await serveHarness();
|
||
const page = await newPage("the wallet export", ctx);
|
||
page.on("pageerror", () => {});
|
||
try {
|
||
const frame = await setupBrokerPage(page, url);
|
||
await frame.waitForFunction(
|
||
() => (window as unknown as { __sdk?: unknown }).__sdk !== undefined,
|
||
{ timeout: 30000 },
|
||
);
|
||
await frame.waitForFunction(
|
||
() => (window as unknown as { __sdk: { status(): string } }).__sdk.status() === "connected",
|
||
{ timeout: 60000 },
|
||
);
|
||
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one
|
||
// of the two ways this harness used to hang for ever. The whole export measures ~7s.
|
||
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
|
||
frame.evaluate(() =>
|
||
(
|
||
window as unknown as {
|
||
__sdk: { exportWalletFile(): Promise<{ walletName: string; b64: string; len: number }> };
|
||
}
|
||
).__sdk.exportWalletFile(),
|
||
),
|
||
);
|
||
fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64"));
|
||
return exported.len;
|
||
} finally {
|
||
await closeQuietly("the wallet export page", () => page.close());
|
||
close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
|
||
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
|
||
* this the profile holds the wallet — but NOT the repos' local cache — so the next
|
||
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
|
||
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
|
||
*
|
||
* The password is a PARAMETER, defaulting to the batch wallet's. The applicative suite
|
||
* reads it off the access gate's own screen and passes it here — which is the only way a
|
||
* test can tell that what the barrier DISPLAYS is what actually opens the file. Hard-coding
|
||
* it here would make that step untestable: the import would succeed on a barrier showing
|
||
* anything at all, including nothing.
|
||
*/
|
||
export async function importWalletViaFile(
|
||
page: Page,
|
||
ngwPath: string,
|
||
password: string = WALLET_PASSWORD,
|
||
): Promise<void> {
|
||
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
|
||
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
|
||
await page.waitForTimeout(3000);
|
||
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
|
||
await page.setInputFiles('input[type=file]', ngwPath);
|
||
const passwordInput = page.locator('input[type=password]').first();
|
||
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
||
await passwordInput.fill(password);
|
||
await passwordInput.press("Enter");
|
||
const confirm = page.getByRole("button", { name: /Confirm/i });
|
||
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
||
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
||
}
|
||
|
||
/**
|
||
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
|
||
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
|
||
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
|
||
*/
|
||
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
|
||
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
|
||
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 — the screens, the clicks, and the application's frame
|
||
* attaching. Measured 1.3–2.8s for an actor and 1.7s on a cold profile's barrier passage
|
||
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45s ≈ 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 {@link 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, which is the failure mode `notebook.ts` documents at length.
|
||
*/
|
||
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 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 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
|
||
* hands the page over on its own, inside `init()`, once the identity is settled — and a
|
||
* journey that walks a first-time user through the 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, `?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 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 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();
|
||
}
|
||
}
|