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 `