From 1271d48e9f8aeb50778cb5525e58b9dd502d0629 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 16 Aug 2026 14:16:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(e2e):=20la=20m=C3=A9canique=20de=20tes?= =?UTF-8?q?t=20devient=20un=20paquet=20=C3=A0=20part,=20ng-e2e-helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .project/concepts/e2e-harness/_debt.md | 11 + packages/ng-e2e-helpers/package.json | 22 + packages/ng-e2e-helpers/src/broker.ts | 423 ++++++++ packages/ng-e2e-helpers/src/browser.ts | 113 +++ .../e2e => ng-e2e-helpers/src}/deadline.ts | 47 +- packages/ng-e2e-helpers/src/index.ts | 92 ++ packages/ng-e2e-helpers/src/known-failures.ts | 106 ++ .../e2e => ng-e2e-helpers/src}/measure.ts | 0 packages/ng-e2e-helpers/src/nextgraph-ui.ts | 186 ++++ packages/ng-e2e-helpers/src/profiles.ts | 140 +++ packages/ng-e2e-helpers/src/report.ts | 221 +++++ packages/ng-e2e-helpers/src/serve.ts | 41 + .../ng-e2e-helpers/src/wallet-export-page.ts | 61 ++ packages/ng-e2e-helpers/src/wallet.ts | 258 +++++ packages/ng-e2e-helpers/tsconfig.json | 8 + packages/polyfill/e2e/broker.ts | 911 ------------------ packages/polyfill/e2e/harness-page.ts | 73 ++ packages/polyfill/e2e/notebook.ts | 282 ++---- .../polyfill/e2e/reactivity-doc-subscribe.ts | 20 +- packages/polyfill/e2e/repro-fresh-wallet.ts | 23 +- packages/polyfill/e2e/run-lock.ts | 131 --- packages/polyfill/e2e/run.ts | 51 +- 22 files changed, 1912 insertions(+), 1308 deletions(-) create mode 100644 .project/concepts/e2e-harness/_debt.md create mode 100644 packages/ng-e2e-helpers/package.json create mode 100644 packages/ng-e2e-helpers/src/broker.ts create mode 100644 packages/ng-e2e-helpers/src/browser.ts rename packages/{polyfill/e2e => ng-e2e-helpers/src}/deadline.ts (82%) create mode 100644 packages/ng-e2e-helpers/src/index.ts create mode 100644 packages/ng-e2e-helpers/src/known-failures.ts rename packages/{polyfill/e2e => ng-e2e-helpers/src}/measure.ts (100%) create mode 100644 packages/ng-e2e-helpers/src/nextgraph-ui.ts create mode 100644 packages/ng-e2e-helpers/src/profiles.ts create mode 100644 packages/ng-e2e-helpers/src/report.ts create mode 100644 packages/ng-e2e-helpers/src/serve.ts create mode 100644 packages/ng-e2e-helpers/src/wallet-export-page.ts create mode 100644 packages/ng-e2e-helpers/src/wallet.ts create mode 100644 packages/ng-e2e-helpers/tsconfig.json delete mode 100644 packages/polyfill/e2e/broker.ts create mode 100644 packages/polyfill/e2e/harness-page.ts delete mode 100644 packages/polyfill/e2e/run-lock.ts diff --git a/.project/concepts/e2e-harness/_debt.md b/.project/concepts/e2e-harness/_debt.md new file mode 100644 index 0000000..8e2f699 --- /dev/null +++ b/.project/concepts/e2e-harness/_debt.md @@ -0,0 +1,11 @@ +# Doc-debt — e2e-harness + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/e2e/harness-page.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/run.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/reactivity-doc-subscribe.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/repro-fresh-wallet.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/notebook.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/packages/ng-e2e-helpers/package.json b/packages/ng-e2e-helpers/package.json new file mode 100644 index 0000000..0ad4b18 --- /dev/null +++ b/packages/ng-e2e-helpers/package.json @@ -0,0 +1,22 @@ +{ + "name": "ng-e2e-helpers", + "version": "0.0.0", + "type": "module", + "description": "End-to-end testing machinery for a NextGraph application: mint and carry a wallet, cross the broker, per-run browser profiles, bounded waits that name what they were waiting for, and a run report whose size does not depend on what failed.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "peerDependencies": { + "playwright": ">=1.40.0", + "@ng-org/web": ">=0.1.2-alpha.13" + }, + "devDependencies": { + "@ng-org/web": "0.1.2-alpha.13", + "playwright": "^1.61.1" + }, + "scripts": { + "typecheck": "bunx tsc --noEmit -p tsconfig.json" + } +} diff --git a/packages/ng-e2e-helpers/src/broker.ts b/packages/ng-e2e-helpers/src/broker.ts new file mode 100644 index 0000000..82bca2a --- /dev/null +++ b/packages/ng-e2e-helpers/src/broker.ts @@ -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 { + 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; + stop(): void; +} + +function watchForAppFrame(page: Page, appOrigin: string): AppFrameWatcher { + const pick = (): Frame | null => { + for (const f of page.frames()) { + if (f === page.mainFrame()) continue; + if (f.url().startsWith(appOrigin)) return f; + } + return null; + }; + let settle: ((f: Frame) => void) | null = null; + const appeared = new Promise((resolve) => { + settle = resolve; + }); + const check = (): void => { + const f = pick(); + if (f !== null && settle !== null) { + settle(f); + settle = null; + } + }; + page.on("frameattached", check); + page.on("framenavigated", check); + check(); + return { + found: pick, + whenFound: () => appeared, + stop: () => { + page.off("frameattached", check); + page.off("framenavigated", check); + }, + }; +} + +type BrokerEvent = + | { kind: "frame"; frame: Frame } + | { kind: "screen"; screen: BrokerScreen } + /** Nothing happened before the bound. `because` is set when the watch itself failed (a + * closed browser, say) rather than simply running out of time — reporting the two the same + * way is how "the screen never changed" gets blamed for a dead browser. */ + | { kind: "stalled"; because: string | null }; + +/** + * Whichever comes first: a screen that is not `previous`, or the application's frame. + * + * The race is not an optimisation. The successful end of this flow leaves the page on a screen + * that never changes again ("Wallet opened for …", which reads as `working`), so a wait for a + * screen CHANGE alone would sit there until its deadline with the frame it wanted already + * attached. + */ +async function nextBrokerEvent( + page: Page, + watcher: AppFrameWatcher, + previous: BrokerScreen | null, + ms: number, +): Promise { + const onScreen = page + .waitForFunction(readBrokerScreen, { previous, screens: BROKER_SCREENS }, { timeout: ms, polling: SCREEN_POLL_MS }) + .then(async (handle): Promise => { + const value = await handle.jsonValue(); + return typeof value === "string" + ? { kind: "screen", screen: value as BrokerScreen } + : { kind: "stalled", because: null }; + }); + // Absorbed, so the loser of the race cannot surface as an unhandled rejection minutes after + // the winner has already been acted on — the same hazard `deadline.ts` documents. A plain + // expiry is NOT an error worth quoting; anything else is, and is quoted. + const settled = onScreen.catch((e: unknown): BrokerEvent => { + const message = String((e as Error)?.message ?? e).split("\n")[0] ?? ""; + const expired = (e as Error)?.name === "TimeoutError" || /Timeout .* exceeded/i.test(message); + return { kind: "stalled", because: expired ? null : message }; + }); + const onFrame = watcher.whenFound().then((frame): BrokerEvent => ({ kind: "frame", frame })); + return Promise.race([settled, onFrame]); +} + +/** + * Answer a screen, 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 { + const click = async (what: string, locator: Locator): Promise => { + try { + await locator.first().click({ timeout: CLICK_MS }); + return `clicked ${what}`; + } catch (e) { + return `could NOT click ${what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`; + } + }; + 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 { + 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 { + const appOrigin = new URL(appUrl).origin; + const startedAt = Date.now(); + const deadline = startedAt + BROKER_LOGIN_MS; + const trail: string[] = []; + const actedOn = new Map(); + let screen: BrokerScreen | null = null; + + const 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(); + } +} diff --git a/packages/ng-e2e-helpers/src/browser.ts b/packages/ng-e2e-helpers/src/browser.ts new file mode 100644 index 0000000..e3d8a6c --- /dev/null +++ b/packages/ng-e2e-helpers/src/browser.ts @@ -0,0 +1,113 @@ +/** + * Every browser this harness opens, launched and watched the same way. + */ + +import { chromium, type BrowserContext, type Page } from "playwright"; +import { + CONTEXT_ACTION_MS, + CONTEXT_NAVIGATION_MS, + browserLost, + closeQuietly, + within, +} from "./deadline"; + +/** Launching a browser is local — 30 s is Playwright's own default, doubled. */ +export const LAUNCH_MS = 60_000; + +/** + * Opening a page in a live browser is instant — measured 0.0–0.1 s over a run. Bounded at + * 10 s, which is a hundred times the measurement and still fails while a reader is watching. + * Exported because 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; + +/** + * What this harness needs Chromium to allow: the application under test is served from + * `127.0.0.1` and loaded inside a broker iframe on a public origin, which is a private-network + * request and a cross-origin one at once. + */ +const LAUNCH_ARGS = [ + "--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations", + "--allow-insecure-localhost", + "--disable-web-security", +]; + +/** + * A real Chromium rather than the headless shell: the shell has no support for the extensions + * of a full browser, and the wallet application's flows have been observed only on the full + * build. Falls back to Playwright's own choice when no full build is installed beside it. + */ +function resolveChromePath(): string | undefined { + const p = chromium + .executablePath() + .replace("chrome-headless-shell", "chrome") + .replace("chromium_headless_shell", "chromium"); + return p.includes("headless") ? undefined : p; +} + +/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */ +const closingOnPurpose = new WeakSet(); + +/** + * 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: a 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. + */ +export async function launchWatchedContext(label: string, dir: string): Promise { + 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): void => { + 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 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 + // (and `known-failures.ts` is what turns the deadline back into the right name). + // + // 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 { + 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 { + return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage()); +} diff --git a/packages/polyfill/e2e/deadline.ts b/packages/ng-e2e-helpers/src/deadline.ts similarity index 82% rename from packages/polyfill/e2e/deadline.ts rename to packages/ng-e2e-helpers/src/deadline.ts index e4dd8cb..85a6f4d 100644 --- a/packages/polyfill/e2e/deadline.ts +++ b/packages/ng-e2e-helpers/src/deadline.ts @@ -1,13 +1,12 @@ /** - * Deadlines for the e2e harnesses — so a wait that cannot end FAILS, named, instead of - * hanging. + * Deadlines — so a wait that cannot end FAILS, named, instead of hanging. * * ── Why this module exists ─────────────────────────────────────────────────── * A harness that hangs is worse than one that fails. A failure names a suspect and costs a * minute; a hang costs an hour and leaves every measurement of the session undecidable — * was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites - * lean on have NO bound at all: `frame.evaluate()` (which is what every `sdk(...)` call in - * `run.ts` is) and `context.newPage()`. Playwright applies no timeout to either. + * lean on have NO bound at all: `frame.evaluate()` (which is what every bridge call into a + * page is) and `context.newPage()`. Playwright applies no timeout to either. * * And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away * mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies @@ -133,6 +132,39 @@ export function browserLost(reason: string): void { for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where)); } +/** + * The loss already declared, or `null` while the run still has a browser. + * + * Read by the failure-mode recognition (`known-failures.ts`) so that a diagnosis asked for + * AFTER a loss answers instantly with the loss, instead of spending a probe's bound + * re-discovering what is already known. + */ +export function lossDeclared(): string | null { + return lost; +} + +/** + * An ENCLOSING bound, computed from the bounds it encloses rather than picked. + * + * ── Why this is an addition and not a comment ──────────────────────────────── + * An enclosing deadline shorter than its own steps can only ever fire FIRST, so every + * failure underneath it is reported as "the enclosure timed out" and the step that actually + * hung is never named. Observed at length: a sign-in bounded at 3 min sat over steps + * totalling 4.5 min, and for days every sign-in failure said the same four words while the + * real step stayed anonymous. Days went into looking for a cause the harness was + * structurally incapable of reporting. + * + * A comment saying "keep this above the sum" is a discipline; an addition is a mechanism, + * and the mechanism is what survives the next edit — shrink a step's bound and the + * enclosure shrinks with it. That is the lever: the steps, never the enclosure. + * + * `margin` is for the enclosure's OWN overhead (the code between the steps), not for + * comfort — an enclosure sized "generously" above its steps just delays every report. + */ +export function enclosingBound(steps: readonly number[], margin: number): number { + return steps.reduce((sum, ms) => sum + ms, 0) + margin; +} + /** Teardown bound: a close that has not returned in 30s is not going to. */ export const CLOSE_MS = 30_000; @@ -169,7 +201,7 @@ export async function closeQuietly(what: string, close: () => Promise): * * `thenReport` lets a suite print its own summary before the process goes — without it a run * that trips this watchdog reports its waits and then vanishes, so its check count is zero - * and comparable with nothing. (`notebook.ts` passes its `finish`.) + * and comparable with nothing. (A suite built on `report.ts` passes its `finish`.) */ export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void { const startedAt = Date.now(); @@ -216,3 +248,8 @@ export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => v */ export const CONTEXT_ACTION_MS = 30_000; export const CONTEXT_NAVIGATION_MS = 30_000; + +/** The first line of whatever was thrown — the form a report carries. */ +export function firstLine(e: unknown): string { + return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)"; +} diff --git a/packages/ng-e2e-helpers/src/index.ts b/packages/ng-e2e-helpers/src/index.ts new file mode 100644 index 0000000..9bd79b4 --- /dev/null +++ b/packages/ng-e2e-helpers/src/index.ts @@ -0,0 +1,92 @@ +/** + * `ng-e2e-helpers` — what any NextGraph application needs to test itself end to end, against + * the real broker and the real wallet application. + * + * ── What it is for ─────────────────────────────────────────────────────────── + * Testing a NextGraph application end to end means getting a real person into it: minting a + * wallet by driving the wallet application, crossing the broker, and coming back inside the + * iframe the application actually runs in. None of that is about any one application, and all + * of it is expensive to get right — the crossing alone has cost days of misdiagnosis, twice, + * for reasons recorded in `broker.ts` and `known-failures.ts`. + * + * It knows nothing about any compatibility layer and never will: an application that uses the + * NextGraph SDK directly is its intended consumer. + * + * ── The five things it gives you ───────────────────────────────────────────── + * - a WALLET: minted for this run, exported as bytes an application can serve, imported into + * a profile (`wallet.ts`); + * - the BROKER CROSSING, which dispatches on the screen it can see and identifies the + * application by ORIGIN (`broker.ts`, `nextgraph-ui.ts`); + * - PROFILES that belong to one run and are cleaned up after it (`profiles.ts`, `browser.ts`); + * - BOUNDS that turn a hang into a named failure (`deadline.ts`, `measure.ts`); + * - a REPORT whose size does not depend on what failed (`report.ts`), and the recognition of + * the failure modes that are not the application's fault (`known-failures.ts`). + * + * Playwright and `@ng-org/web` are peer dependencies: the consumer owns both versions — the + * first because browser binaries have to match the driver, the second because the SDK the + * export page opens a session with must be the one the application and the broker agree on. + */ + +export { + BrowserGone, + CLOSE_MS, + CONTEXT_ACTION_MS, + CONTEXT_NAVIGATION_MS, + DeadlineExceeded, + armSuiteDeadline, + browserLost, + closeQuietly, + enclosingBound, + firstLine, + lossDeclared, + within, +} from "./deadline"; + +export { measured, printTimings, record, timingsWanted } from "./measure"; + +export { LAUNCH_MS, NEW_PAGE_MS, closeContext, launchWatchedContext, newPage } from "./browser"; + +export { isAlive, newRunProfile, type RunProfile } from "./profiles"; + +export { serveOnEphemeralPort } from "./serve"; + +export { BROKER_LOGIN_MS, BROKER_ROUND_TRIP_MS, completeBrokerLogin, setupBrokerPage } from "./broker"; + +export { + createWalletInContext, + emptyProfileContext, + exportWalletFile, + importWalletFile, + mintWalletProfile, + mintWalletProfileKeepingContext, + type WalletCredentials, +} from "./wallet"; + +export { + BROWSER_PROBE_MS, + FRAME_PROBE_MS, + browserTrouble, + frameTrouble, +} from "./known-failures"; + +export { + declareSuite, + type JourneyDeclaration, + type JourneySpec, + type Prerequisite, + type SuiteOptions, + type SuiteReport, +} from "./report"; + +export { + BROKER_SCREENS, + WALLET_APP, + WALLET_CREATION, + WALLET_IMPORT, + brokerRedirectFor, + type BrokerScreen, + type BrokerScreenSpec, + type ScreenResponse, + type ScreenSignature, + type TextPattern, +} from "./nextgraph-ui"; diff --git a/packages/ng-e2e-helpers/src/known-failures.ts b/packages/ng-e2e-helpers/src/known-failures.ts new file mode 100644 index 0000000..21ab295 --- /dev/null +++ b/packages/ng-e2e-helpers/src/known-failures.ts @@ -0,0 +1,106 @@ +/** + * The failure modes this harness cannot fix, and must therefore NAME. + * + * ── Why naming is the whole of the job ─────────────────────────────────────── + * Both modes below present as a bounded wait expiring on whatever operation happened to be in + * flight — a `fill`, a `selectOption`, a `click`. Reported that way they read as product + * defects, and they have been diagnosed as such more than once: a run whose browser had + * stopped answering reported three timeouts on three different innocent selectors, none of + * them naming the browser. A whole day went into one of those. + * + * So when a wait fails, the honest question is asked before the verdict is written: does the + * browser still answer AT ALL? A trivial round-trip settles it in milliseconds when things are + * healthy, so asking costs a healthy run nothing. + * + * ── The two modes ──────────────────────────────────────────────────────────── + * **A dropped devtools pipe.** Chromium's control pipe drops mid-run: it logs a terminated-pipe + * message and exits cleanly, and Playwright emits NEITHER `close` NOR `disconnected` — observed + * four times out of four. From the client's side the browser simply stops answering, so every + * wait on it burns its bound and the unbounded ones wait for ever. It is not caused by how the + * child process is spawned, nor by a leftover holding the profile, nor by overlapping launches + * — all three were probed and ruled out. It looks like Playwright losing its file descriptors + * without telling its client. + * + * **A context that stops answering.** The same shape at frame level: a frame that is attached, + * on the right URL, and holds NOTHING — what a RELOADED iframe looks like from the outside. + * VERIFIED 2026-08-16, one actor's frame reached it mid-run and the next three journeys each + * reported a 30 s timeout on a different innocent selector. + * + * ── What a named deadline does NOT prove ───────────────────────────────────── + * That the transport is at fault. A deadline says only that something did not happen in time; + * reaching for the environment is the comfortable answer because it absolves the code. The + * worst instance of that reflex here was a one-line harness bug — an application frame matched + * by SUBSTRING — blamed on the broker and on the host network for a day. Read your own harness + * first, and call it transport only once you can name the mechanism. + */ + +import type { BrowserContext, Frame, Page } from "playwright"; +import { DeadlineExceeded, firstLine, lossDeclared, within } from "./deadline"; + +/** Asking a live browser something trivial: it answers in milliseconds, or it is gone. */ +export const BROWSER_PROBE_MS = 5_000; +/** + * Asking a live frame whether it still holds the application. A `count()` is one round-trip + * and does not wait for the element, so it answers in milliseconds or the frame is gone — the + * probe cannot itself become the hang it exists to name. + */ +export const FRAME_PROBE_MS = 10_000; + +/** + * Whether the browser has stopped answering — as a sentence naming the mode, or `null` when it + * answers normally and the operation that failed is the real suspect. + * + * Consult this on a failure path, never in the success path: it exists to REPLACE a misleading + * verdict, not to add a check. + */ +export async function browserTrouble(label: string, ctx: BrowserContext): Promise { + // Already established, by the `close`/`disconnected` listeners that can see their losses. + // Answering from it costs nothing and says the same thing. + const declared = lossDeclared(); + if (declared !== null) return declared; + + const live = ctx.pages().filter((p) => !p.isClosed()); + if (live.length === 0) return null; // nothing to ask — no verdict, rather than a wrong one + const page = live[0]!; + try { + await within(`the ${label} browser to answer a trivial question`, BROWSER_PROBE_MS, () => + page.evaluate(() => 1), + ); + return null; + } catch (e) { + if (e instanceof DeadlineExceeded) { + return ( + `the ${label} browser STOPPED ANSWERING — a trivial round-trip did not come back in ` + + `${BROWSER_PROBE_MS / 1000}s. This is the dropped devtools pipe (Chromium exits and ` + + "Playwright emits neither `close` nor `disconnected`), so whatever operation was in " + + "flight is a casualty and not the cause. It is not ours to fix — re-run, and do not " + + "read this as a verdict on the code under test" + ); + } + return `the ${label} browser refused a trivial question: ${firstLine(e)}`; + } +} + +/** + * Why `frame` cannot be driven, or `null` when it can. + * + * `marker` is the selector that proves the application is still in the frame — the caller's, + * because only the application knows what its own presence looks like. The THIRD state is the + * one that actually happens and the one no naive check catches: attached, on the right URL, + * and empty. + */ +export async function frameTrouble( + id: string, + page: Page, + frame: Frame, + marker: string, +): Promise { + if (page.isClosed()) return `${id}'s page has been closed`; + if (frame.isDetached()) return `${id}'s application frame is detached`; + const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () => + frame.locator(marker).count(), + ).catch((e: unknown) => firstLine(e)); + if (typeof shell === "string") return `${id}'s frame did not answer (${shell})`; + if (shell === 0) return `${id}'s frame no longer holds the application — it reloaded`; + return null; +} diff --git a/packages/polyfill/e2e/measure.ts b/packages/ng-e2e-helpers/src/measure.ts similarity index 100% rename from packages/polyfill/e2e/measure.ts rename to packages/ng-e2e-helpers/src/measure.ts diff --git a/packages/ng-e2e-helpers/src/nextgraph-ui.ts b/packages/ng-e2e-helpers/src/nextgraph-ui.ts new file mode 100644 index 0000000..971c40b --- /dev/null +++ b/packages/ng-e2e-helpers/src/nextgraph-ui.ts @@ -0,0 +1,186 @@ +/** + * What NextGraph's own pages LOOK like — addresses, selectors, and the inventory of screens + * the sign-in walks through. Description only: nothing here drives a browser. + * + * ── Why it is a separate file, and why it is data ──────────────────────────── + * Two kinds of knowledge live in this package and they age at completely different rates. + * How to cross a broker — dispatch on the screen you can see, never on elapsed time; identify + * the application by its origin, never by a substring — is a *method*, and it has survived + * every change upstream. WHICH selector shows a wallet list is a *fact about today's markup*, + * and it changes whenever the wallet application is restyled. + * + * Keeping the second kind as plain data has two consequences worth the split. Upstream + * changes a selector: you edit a string in this file and no control flow moves. And the + * driving code below (`broker.ts`, `wallet.ts`) reads this inventory rather than embedding + * it, so a harness built on some other browser driver would reuse this file whole and + * rewrite only the driving. That adapter is NOT built here — the point is only that + * building it would not be a rewrite. + * + * The screen inventory is deliberately SERIALIZABLE: it is handed to the browser as an + * argument (see `readBrokerScreen` in `broker.ts`), so the same description that names a + * screen in a failure message is the one the recognition dispatched on. That rules out + * regular expressions as values, hence {@link TextPattern}. + * + * VERIFIED 2026-08-14 against the live pages unless noted; the upstream source is + * `nextgraph-rs` (`infra/ngnet/redir`, `engine/broker/auth`, `app/ui-common`), read but + * never modified. + */ + +// ── the wallet application (nextgraph.eu) ─────────────────────────────────── + +/** + * Where a wallet is created and where one is imported. The wallet application is a real + * application like any other — this harness drives its actual interface rather than + * reaching behind it, because a wallet obtained any other way is not the one a person has. + */ +export const WALLET_APP = { + home: "https://nextgraph.eu/", + /** The standalone import/unlock route, reachable without going through the broker. */ + login: "https://nextgraph.eu/#/wallet/login", +} as const; + +/** The creation flow, screen by screen, as labels and selectors. */ +export const WALLET_CREATION = { + /** Step 1 — the home page's entry point. */ + createWallet: "Create Wallet", + /** Step 2 — the terms screen, reached on the `/account` route. */ + acceptTerms: "I accept", + /** The URL glob that route is awaited by. */ + termsRoute: "**/account*", + /** Step 3 — the credentials form. */ + username: "#username-input", + password: "#password-input", + /** Matched loosely: the button's caption is not stable in case. */ + submit: "create my wallet", + /** Step 4 — creation lands here, and the first unlock happens from it. */ + landsOn: "**/#/wallet/login", + /** Offered on the login route when a wallet is already on the device. */ + loginWithThisWallet: "Click here to login with your wallet", + passwordField: 'input[type="password"]', +} as const; + +/** The import-a-wallet-file flow on the same login route. */ +export const WALLET_IMPORT = { + fileInput: "input[type=file]", + passwordField: "input[type=password]", + /** Shown by some builds after the password; absent in others, so it is probed, not awaited. */ + confirm: /Confirm/i, +} as const; + +// ── the broker crossing (nextgraph.net/redir → the broker's auth page) ────── + +/** The redirect that hands an application's address to the broker. */ +export function brokerRedirectFor(appUrl: string): string { + return `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`; +} + +/** + * The distinct screens the crossing can be on. + * + * - `choose-broker` — the redirect page with MORE than one broker to pick from. Not observed + * on hosts that resolve to a single broker (which auto-selects), so it is described 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: the wallet is + * broadcast between the broker origin's tabs over a `BroadcastChannel` named `ng_wallet`, + * so a later actor's wallet is already in `opened_wallets` and selecting it logs straight + * in (`ui-common/src/routes/WalletLogin.svelte`, the `$opened_wallets[selected]` path). + * VERIFIED 2026-08-14, three consecutive sign-ins in one browser context. + * - `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 application's frame is watched separately + * rather than inferred from the screen. + * - `error` — the broker said no ("An error occurred", "Invalid request"). Terminal. + */ +export type BrokerScreen = + | "choose-broker" + | "login-offered" + | "wallet-list" + | "password" + | "working" + | "error"; + +/** + * A regular expression as data, because the inventory crosses into the browser and a + * `RegExp` does not survive that trip. Rebuilt on the far side with `new RegExp(...)`. + */ +export interface TextPattern { + readonly source: string; + readonly flags: string; +} + +/** How a screen is told apart from the ones described BEFORE it. */ +export type ScreenSignature = + /** Any of these selectors matches an element with a non-zero box. */ + | { readonly kind: "rendered"; readonly selectors: readonly string[] } + /** A rendered `