Files
ng-eventually/packages/polyfill/e2e/broker.ts
T
Sylvain Duchesne 7062364569 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.
2026-08-14 10:56:49 +02:00

883 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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; a minute means the browser is not answering. */
const NEW_PAGE_MS = 60_000;
/** The whole wallet export measures ~7s against the real broker; two minutes is a hang. */
const EXPORT_MS = 120_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.01.6 s (VERIFIED, three
// consecutive sign-ins). A 2-second bound on a 1.01.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.63.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 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();
}
}