refactor(e2e): la mécanique de test devient un paquet à part, ng-e2e-helpers

Créer un portefeuille, en obtenir le .ngw, traverser le broker : ce n'est pas du
ressort du polyfill. C'est un besoin commun au polyfill et à toute application
NextGraph — et surtout, ça SURVIT à la migration, alors que le polyfill est fait
pour disparaître. L'y laisser, c'était le faire mourir avec lui ou rendre le
polyfill indéracinable.

Le paquet n'importe rien du polyfill — vérifié mécaniquement — et déclare
Playwright et @ng-org/web en pairs, le consommateur devant maîtriser les
versions. Sa surface : attentes bornées, mesure, navigateur, profils,
portefeuille, traversée du broker, rapport d'exécution, et reconnaissance des
modes de panne connus.

La preuve qu'il est utilisable de l'extérieur : le polyfill le CONSOMME, sans
garder de copie. Restent chez lui les parcours, la barrière et les identités
virtuelles, qui lui sont propres.

Le verrou entre exécutions disparaît, remplacé par un profil par exécution. Il
ne traitait qu'un symptôme — un répertoire partagé que la création de
portefeuille effaçait. Avec un profil par exécution il n'y a plus rien à
sérialiser, les exécutions concurrentes deviennent indépendantes, et la
collision entre deux dépôts s'évanouit au lieu d'être exportée. Six exécutions :
aucun répertoire ni Chromium orphelin.

Et la connaissance descriptive est séparée du pilotage : URL, sélecteurs et
inventaire ordonné des écrans sont des données, passées DANS la page pour la
reconnaissance — donc un échec nomme le même écran que celui sur lequel on
dispatchait.

Un échec de navigateur est désormais nommé comme tel — « the actors browser
STOPPED ANSWERING » — au lieu de sortir sous le nom de l'opération innocente qui
se trouvait en vol.
This commit is contained in:
Sylvain Duchesne
2026-08-16 14:16:11 +02:00
parent cf3c7c7d8b
commit 1271d48e9f
22 changed files with 1912 additions and 1308 deletions
+423
View File
@@ -0,0 +1,423 @@
/**
* The broker crossing — `nextgraph.net/redir` → the broker's auth page → the wallet list →
* (sometimes) the password → nested iframes → the application's own frame.
*
* ── The defect this replaces, and why it looked like the network ─────────────
* An earlier 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 an element that page does not have, 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 a 2-second visibility
* probe, 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. 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.
*
* The screens themselves, their signatures and their answers are DESCRIPTION and live in
* `nextgraph-ui.ts`; this file is the driving.
*/
import type { Frame, Locator, Page } from "playwright";
import { CONTEXT_NAVIGATION_MS, within } from "./deadline";
import { browserTrouble } from "./known-failures";
import {
BROKER_SCREENS,
brokerRedirectFor,
type BrokerScreen,
type BrokerScreenSpec,
} from "./nextgraph-ui";
/**
* One bound for the whole ceremony — the screens, the clicks, and the application's frame
* attaching. Measured 1.3–2.8 s for an actor and 1.7 s on a cold profile's barrier passage
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45 s ≈ 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 `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.
*/
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 15 s 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;
/**
* Navigate through the broker's redirect to load `appUrl` in the broker iframe, unlock the
* wallet if a login is shown, and return the application's frame.
*/
export async function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
await page.goto(brokerRedirectFor(appUrl), { waitUntil: "domcontentloaded" });
return completeBrokerLogin(page, appUrl, walletPassword);
}
/**
* 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. The screen inventory is therefore an ARGUMENT, not an import
* — which is also what lets the description live in one file and the driving in another. 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.
*/
function readBrokerScreen(input: {
previous: string | null;
screens: readonly BrokerScreenSpec[];
}): 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 | null = null;
for (const spec of input.screens) {
const signature = spec.signature;
if (signature.kind === "rendered") {
if (signature.selectors.some((selector) => shown(document.querySelector(selector)))) {
kind = spec.screen;
}
} else if (signature.kind === "rendered-control") {
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const control = Array.from(document.querySelectorAll("button, a")).find((el) =>
pattern.test((el.textContent ?? "").trim()),
);
if (shown(control ?? null)) kind = spec.screen;
} else if (signature.kind === "page-text") {
// `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
// would otherwise answer for a page that is not showing it.
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const visible = document.body === null ? "" : document.body.innerText;
if (pattern.test(visible)) kind = spec.screen;
} else {
kind = spec.screen;
}
if (kind !== null) break;
}
if (kind === null) return false;
return kind === input.previous ? false : kind;
}
/**
* The application's frame: a SUB-frame whose URL is on the application's own origin.
*
* ── Both halves of that sentence are load-bearing ────────────────────────────
* `startsWith(origin)` rather than `includes(host)` is what stops the broker's own pages from
* answering. THE BROKER'S AUTH PAGE CARRIES THE APPLICATION'S ADDRESS IN ITS QUERY STRING, so
* a substring match returns the LOGIN PAGE and the whole crossing then fails silently: the
* wallet click and the password are skipped as "already logged in", and the caller is handed a
* frame that will never render the application. That bug cost days, and it was readable in one
* line the whole time. Never match a host, a port or a path here — match the origin, from the
* start of the URL.
*
* 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, screens: BROKER_SCREENS }, { 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, as its description says to. 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 — which says far more than the click's
* own timeout would.
*/
async function answerBrokerScreen(
page: Page,
spec: BrokerScreenSpec,
walletPassword: string,
): 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]}`;
}
};
const answer = spec.answer;
switch (answer.kind) {
case "click":
return click(answer.what, page.locator(answer.selector));
case "click-text":
return click(answer.what, page.getByText(answer.text, { exact: true }));
case "submit-password": {
const field = page.locator(answer.selector).first();
try {
await field.fill(walletPassword, { timeout: CLICK_MS });
await field.press("Enter", { timeout: CLICK_MS });
return `filled ${answer.what} and submitted it`;
} catch (e) {
return `could NOT submit ${answer.what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
}
case "wait":
return null;
}
}
/**
* Why the crossing did not get where it was going — with enough on it to skip the guessing.
*
* An earlier version said "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.
*
* And, when it applies, the browser's own condition FIRST: a crossing that failed because the
* browser stopped answering must not be reported as a broker problem.
*/
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);
const trouble = await browserTrouble("crossing", page.context());
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` +
(trouble === null ? "" : ` BUT FIRST: ${trouble}\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 crossing 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, once the identity is settled — and a journey that walks a first-time
* user through an access 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, its query parameters included — i.e. it would quietly substitute the suite's path
* for the one under test.
*
* ── A password may simply never be asked for ─────────────────────────────────
* The second actor is a BRANCH, not a fallthrough: the wallet is broadcast between the broker
* origin's tabs, so a later actor's selection logs straight in. The machine below neither
* expects nor requires the password screen — it answers what is on screen. An earlier 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, walletPassword: 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}`);
const spec = BROKER_SCREENS.find((s) => s.screen === screen);
if (spec === undefined) {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
`the page reported a screen the inventory does not describe (${screen})`,
);
}
if (spec.terminal === true) {
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 answerBrokerScreen(page, spec, walletPassword);
if (did !== null) trail.push(` ${did}`);
}
} finally {
watcher.stop();
}
}