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 `