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

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

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

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

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

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

Un échec de navigateur est désormais nommé comme tel — « the actors browser
STOPPED ANSWERING » — au lieu de sortir sous le nom de l'opération innocente qui
se trouvait en vol.
This commit is contained in:
Sylvain Duchesne
2026-08-16 14:16:11 +02:00
parent cf3c7c7d8b
commit 1271d48e9f
22 changed files with 1912 additions and 1308 deletions
+22
View File
@@ -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"
}
}
+423
View File
@@ -0,0 +1,423 @@
/**
* The broker crossing — `nextgraph.net/redir` → the broker's auth page → the wallet list →
* (sometimes) the password → nested iframes → the application's own frame.
*
* ── The defect this replaces, and why it looked like the network ─────────────
* An earlier version decided where it was in the flow by TIMING: a 2-second probe for the
* "Login" button, a 500 ms poll loop, a 1-second settle, an 8-second window for the password
* prompt, a 3-second hope after submitting. Under it sat a predicate that was simply wrong —
* an "application frame" was any frame whose URL CONTAINED `127.0.0.1`.
*
* VERIFIED 2026-08-14 by driving the real pages: the broker's own auth page carries the
* application's address in its query string, so its MAIN frame's URL is
* `https://nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975` — which contains
* `127.0.0.1`. The predicate therefore matched the AUTH PAGE ITSELF, from the first instant,
* before any login had happened. Everything downstream then followed: the poll loop exited at
* once, the wallet click and the password were SKIPPED as "already logged in", and the
* function returned `page.mainFrame()` — the broker's login screen — as the application's
* frame. The caller then waited its full minute for an element that page does not have, and
* reported a timeout naming nothing.
*
* The only thing that ever saved a run was the very first branch: clicking "Login" moves the
* URL to `#/wallet/login`, which carries no `o=` parameter and so no `127.0.0.1` — after which
* the broken predicate happens to behave. That click was guarded by a 2-second visibility
* probe, and the button paints at 1.01.6 s (VERIFIED, three consecutive sign-ins). A 2-second
* bound on a 1.01.6-second event is a coin toss, and which side it lands on is decided by how
* loaded the machine is — which is exactly why this read as "the broker" or "the host
* network", and why it hit the SECOND actor most: it signs in while the first one's tab is
* busy with its own broker traffic.
*
* So nothing here waits for a DURATION. 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.32.8 s for an actor and 1.7 s on a cold profile's barrier passage
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45 s ≈ 16x the slowest measured: enough that a
* busy host does not manufacture a false diagnosis, little enough that the rich failure below
* — the screen, the trail, the frames, the page's own text — arrives in under a minute instead
* of after two.
*
* Exported for the same reason as `NEW_PAGE_MS`: this function's failure message is the most
* informative one in the harness, and an enclosing bound set below it would replace that
* message with "the round-trip timed out" and lose every fact in it.
*/
export const BROKER_LOGIN_MS = 45_000;
/**
* What {@link setupBrokerPage} costs at worst: its navigation plus the ceremony. A caller that
* wants to MEASURE the round-trip should hand this to `measured` rather than invent a bound of
* its own — an enclosure below this number fires before the ceremony can explain itself.
*/
export const BROKER_ROUND_TRIP_MS = CONTEXT_NAVIGATION_MS + BROKER_LOGIN_MS;
/** How often the browser re-reads the screen. Not a sleep: it is the interval of a condition
* check that runs INSIDE the page, the same mechanism `isVisible` uses. */
const SCREEN_POLL_MS = 200;
/** A screen answered this many times without the flow moving on is a livelock, not a slow
* page — say so instead of clicking for ever. */
const SCREEN_REVISITS_ALLOWED = 3;
/** A click or a fill that has not landed in 15 s is not going to; the ceremony's own bound is
* eight times that, so failing here leaves room to say so rather than to hang. */
const CLICK_MS = 15_000;
/** Reading the text of a page for a failure message is a round-trip like any other, and
* `evaluate` carries no bound of its own — a diagnosis must not become the new hang. */
const TEXT_MS = 15_000;
/**
* Navigate through the broker's redirect to load `appUrl` in the broker iframe, unlock the
* wallet if a login is shown, and return the application's frame.
*/
export async function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
await page.goto(brokerRedirectFor(appUrl), { waitUntil: "domcontentloaded" });
return completeBrokerLogin(page, appUrl, walletPassword);
}
/**
* Which screen the page is on — evaluated INSIDE the page, returning `false` while it is still
* the one the caller already knows about, so it doubles as the change detector.
*
* Self-contained on purpose: Playwright ships this function's source into the browser, so it
* may close over nothing at all. The screen inventory is therefore an ARGUMENT, not an import
* — which is also what lets the description live in one file and the driving in another. It is
* passed both to `waitForFunction` (wait for a DIFFERENT screen) and to `evaluate` (read the
* current one): one definition, so the name in a failure message is always the name the
* machine dispatched on.
*/
function readBrokerScreen(input: {
previous: string | null;
screens: readonly BrokerScreenSpec[];
}): string | false {
const shown = (el: Element | null): boolean => {
if (el === null) return false;
const box = el.getBoundingClientRect();
return box.width > 0 && box.height > 0;
};
let kind: string | null = null;
for (const spec of input.screens) {
const signature = spec.signature;
if (signature.kind === "rendered") {
if (signature.selectors.some((selector) => shown(document.querySelector(selector)))) {
kind = spec.screen;
}
} else if (signature.kind === "rendered-control") {
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const control = Array.from(document.querySelectorAll("button, a")).find((el) =>
pattern.test((el.textContent ?? "").trim()),
);
if (shown(control ?? null)) kind = spec.screen;
} else if (signature.kind === "page-text") {
// `innerText`, not `textContent`, and only here: it is the one test that has to read
// prose rather than a selector, and only what is RENDERED counts — the hidden login UI
// would otherwise answer for a page that is not showing it.
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const visible = document.body === null ? "" : document.body.innerText;
if (pattern.test(visible)) kind = spec.screen;
} else {
kind = spec.screen;
}
if (kind !== null) break;
}
if (kind === null) return false;
return kind === input.previous ? false : kind;
}
/**
* The application's frame: a SUB-frame whose URL is on the application's own origin.
*
* ── Both halves of that sentence are load-bearing ────────────────────────────
* `startsWith(origin)` rather than `includes(host)` is what stops the broker's own pages from
* answering. THE BROKER'S AUTH PAGE CARRIES THE APPLICATION'S ADDRESS IN ITS QUERY STRING, so
* a substring match returns the LOGIN PAGE and the whole crossing then fails silently: the
* wallet click and the password are skipped as "already logged in", and the caller is handed a
* frame that will never render the application. That bug cost days, and it was readable in one
* line the whole time. Never match a host, a port or a path here — match the origin, from the
* start of the URL.
*
* Excluding the MAIN frame is the second half, and it is not redundant: a journey that lets the
* application hand the page over ITSELF calls this while the page is still ON the application,
* top-level, and a main-frame match there would hand back a frame that is about to navigate
* away.
*
* Event-driven rather than polled — `frameattached`/`framenavigated` is exactly the signal, so
* there is nothing to sleep between.
*/
interface AppFrameWatcher {
/** The frame now, or null. */
found(): Frame | null;
/** Resolves once one appears. The same promise every time, so racing it costs no listener. */
whenFound(): Promise<Frame>;
stop(): void;
}
function watchForAppFrame(page: Page, appOrigin: string): AppFrameWatcher {
const pick = (): Frame | null => {
for (const f of page.frames()) {
if (f === page.mainFrame()) continue;
if (f.url().startsWith(appOrigin)) return f;
}
return null;
};
let settle: ((f: Frame) => void) | null = null;
const appeared = new Promise<Frame>((resolve) => {
settle = resolve;
});
const check = (): void => {
const f = pick();
if (f !== null && settle !== null) {
settle(f);
settle = null;
}
};
page.on("frameattached", check);
page.on("framenavigated", check);
check();
return {
found: pick,
whenFound: () => appeared,
stop: () => {
page.off("frameattached", check);
page.off("framenavigated", check);
},
};
}
type BrokerEvent =
| { kind: "frame"; frame: Frame }
| { kind: "screen"; screen: BrokerScreen }
/** Nothing happened before the bound. `because` is set when the watch itself failed (a
* closed browser, say) rather than simply running out of time — reporting the two the same
* way is how "the screen never changed" gets blamed for a dead browser. */
| { kind: "stalled"; because: string | null };
/**
* Whichever comes first: a screen that is not `previous`, or the application's frame.
*
* The race is not an optimisation. The successful end of this flow leaves the page on a screen
* that never changes again ("Wallet opened for …", which reads as `working`), so a wait for a
* screen CHANGE alone would sit there until its deadline with the frame it wanted already
* attached.
*/
async function nextBrokerEvent(
page: Page,
watcher: AppFrameWatcher,
previous: BrokerScreen | null,
ms: number,
): Promise<BrokerEvent> {
const onScreen = page
.waitForFunction(readBrokerScreen, { previous, screens: BROKER_SCREENS }, { timeout: ms, polling: SCREEN_POLL_MS })
.then(async (handle): Promise<BrokerEvent> => {
const value = await handle.jsonValue();
return typeof value === "string"
? { kind: "screen", screen: value as BrokerScreen }
: { kind: "stalled", because: null };
});
// Absorbed, so the loser of the race cannot surface as an unhandled rejection minutes after
// the winner has already been acted on — the same hazard `deadline.ts` documents. A plain
// expiry is NOT an error worth quoting; anything else is, and is quoted.
const settled = onScreen.catch((e: unknown): BrokerEvent => {
const message = String((e as Error)?.message ?? e).split("\n")[0] ?? "";
const expired = (e as Error)?.name === "TimeoutError" || /Timeout .* exceeded/i.test(message);
return { kind: "stalled", because: expired ? null : message };
});
const onFrame = watcher.whenFound().then((frame): BrokerEvent => ({ kind: "frame", frame }));
return Promise.race([settled, onFrame]);
}
/**
* Answer a screen, as its description says to. Returns what it did, for the failure message,
* or null if there was nothing to do but wait.
*
* A click that cannot land is REPORTED, not thrown: the loop sees the screen again and the
* revisit cap turns a stuck click into a named failure — which says far more than the click's
* own timeout would.
*/
async function answerBrokerScreen(
page: Page,
spec: BrokerScreenSpec,
walletPassword: string,
): Promise<string | null> {
const click = async (what: string, locator: Locator): Promise<string> => {
try {
await locator.first().click({ timeout: CLICK_MS });
return `clicked ${what}`;
} catch (e) {
return `could NOT click ${what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
};
const answer = spec.answer;
switch (answer.kind) {
case "click":
return click(answer.what, page.locator(answer.selector));
case "click-text":
return click(answer.what, page.getByText(answer.text, { exact: true }));
case "submit-password": {
const field = page.locator(answer.selector).first();
try {
await field.fill(walletPassword, { timeout: CLICK_MS });
await field.press("Enter", { timeout: CLICK_MS });
return `filled ${answer.what} and submitted it`;
} catch (e) {
return `could NOT submit ${answer.what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
}
case "wait":
return null;
}
}
/**
* Why the crossing did not get where it was going — with enough on it to skip the guessing.
*
* An earlier version said "SDK iframe not found after 30s" plus a list of frame URLs, and a
* whole day went into attributing that to the network. So this names the screen the machine
* last recognised, the screens it walked through and what it did on each, the origin it was
* waiting for, every frame, and the text the page was actually showing — which is the one
* thing that distinguishes a broker error page from a page that is simply still working.
*
* And, when it applies, the browser's own condition FIRST: a crossing that failed because the
* browser stopped answering must not be reported as a broker problem.
*/
async function brokerLoginFailure(
page: Page,
appOrigin: string,
screen: BrokerScreen | null,
trail: string[],
startedAt: number,
why: string,
): Promise<Error> {
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
const trouble = await browserTrouble("crossing", page.context());
let shown: string;
try {
const text = await within("the failed broker page's own text", TEXT_MS, () =>
page.evaluate(() => (document.body === null ? "" : document.body.innerText)),
);
const compact = text.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
shown = compact === "" ? "(the page showed nothing at all)" : compact.slice(0, 800);
} catch (e) {
shown = `(could not be read: ${String((e as Error)?.message ?? e).split("\n")[0]})`;
}
const frames = page
.frames()
.map((f) => ` ${f === page.mainFrame() ? "top" : "sub"} ${f.url() === "" ? "(blank)" : f.url()}`);
return new Error(
`[e2e broker login] gave up after ${elapsed}s — ${why}.\n` +
(trouble === null ? "" : ` BUT FIRST: ${trouble}\n`) +
` last screen it recognised: ${screen ?? "(none)"}\n` +
` it was waiting for: a sub-frame whose URL starts with ${appOrigin}\n` +
` how it got here:\n${trail.length === 0 ? " (nothing happened)" : trail.map((s) => ` ${s}`).join("\n")}\n` +
` frames on the page (${frames.length}):\n${frames.join("\n")}\n` +
` what the page was showing:\n${shown
.split("\n")
.map((l) => ` | ${l}`)
.join("\n")}`,
);
}
/**
* The half of {@link setupBrokerPage} that does NOT navigate: walk the crossing from whatever
* screen the page is on, and return the application's frame.
*
* Split out because there are two ways to arrive at the broker, and only one of them is the
* suite's. A test signs the round-trip off itself (`setupBrokerPage`); an APPLICATION hands the
* page over on its own, once the identity is settled — and a journey that walks a first-time
* user through an access barrier has to LET it, because that hand-over IS what it is checking.
* Calling `setupBrokerPage` there would re-navigate and throw away the URL the application had
* just built, its query parameters included — i.e. it would quietly substitute the suite's path
* for the one under test.
*
* ── A password may simply never be asked for ─────────────────────────────────
* The second actor is a BRANCH, not a fallthrough: the wallet is broadcast between the broker
* origin's tabs, so a later actor's selection logs straight in. The machine below neither
* expects nor requires the password screen — it answers what is on screen. An earlier version
* treated the password as the normal case and gave the no-password path an 8-second window to
* prove itself innocent.
*/
export async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
const appOrigin = new URL(appUrl).origin;
const startedAt = Date.now();
const deadline = startedAt + BROKER_LOGIN_MS;
const trail: string[] = [];
const actedOn = new Map<BrokerScreen, number>();
let screen: BrokerScreen | null = null;
const watcher = watchForAppFrame(page, appOrigin);
try {
for (;;) {
const already = watcher.found();
if (already !== null) return already;
const left = deadline - Date.now();
if (left <= 0) {
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "its overall deadline expired");
}
const next = await nextBrokerEvent(page, watcher, screen, left);
if (next.kind === "frame") return next.frame;
if (next.kind === "stalled") {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
next.because !== null
? `watching the page stopped working: ${next.because}`
: screen === null
? "no screen it recognises ever appeared"
: `the ${screen} screen never changed and no application frame ever appeared`,
);
}
screen = next.screen;
trail.push(`+${((Date.now() - startedAt) / 1000).toFixed(1)}s ${screen}`);
const spec = BROKER_SCREENS.find((s) => s.screen === screen);
if (spec === undefined) {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
`the page reported a screen the inventory does not describe (${screen})`,
);
}
if (spec.terminal === true) {
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "the broker showed an error page");
}
const seenBefore = (actedOn.get(screen) ?? 0) + 1;
actedOn.set(screen, seenBefore);
if (seenBefore > SCREEN_REVISITS_ALLOWED) {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
`the ${screen} screen came back ${seenBefore} times — the click it answers is not moving the flow on`,
);
}
const did = await answerBrokerScreen(page, spec, walletPassword);
if (did !== null) trail.push(` ${did}`);
}
} finally {
watcher.stop();
}
}
+113
View File
@@ -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.00.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<BrowserContext>();
/**
* Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied and
* its disappearance turned into an immediate, named failure.
*
* The watch is the load-bearing part. VERIFIED 2026-08-11: 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<BrowserContext> {
const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () =>
chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
timeout: LAUNCH_MS,
}),
);
ctx.setDefaultTimeout(CONTEXT_ACTION_MS);
ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS);
const gone = (how: string): 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<void> {
closingOnPurpose.add(ctx);
await closeQuietly(`the ${label} context`, () => ctx.close());
}
/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */
export function newPage(label: string, ctx: BrowserContext): Promise<Page> {
return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage());
}
+255
View File
@@ -0,0 +1,255 @@
/**
* 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 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
* INSIDE its own cleanup, after its last journey, without ever printing its summary or its
* failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness
* was killed for, three times.
*
* So: every wait that can block gets a deadline, and on expiry an error that says WHAT it
* was waiting for and WHERE — the chain of journeys and steps it sits inside (see
* {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing
* from "which wait" to "which of these thirty-two".
*
* ── Bounds are generous on purpose ───────────────────────────────────────────
* The numbers are sized from OBSERVED healthy timings with a wide margin (see each
* caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a
* bound that fires on a slow broker manufactures exactly the false diagnosis it exists to
* prevent.
*/
/** Thrown when a bounded wait outlives its deadline. */
export class DeadlineExceeded extends Error {
constructor(what: string, ms: number, where: string) {
super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`);
this.name = "DeadlineExceeded";
}
}
/**
* Thrown at every wait in flight when the browser they all depend on has gone away.
*
* Without it, a dead browser is discovered one 60-second timeout at a time — or never, on
* the waits Playwright does not bound. The suite has nothing left to measure at that
* point, so the useful thing is to say so once, immediately, and name the loss.
*/
export class BrowserGone extends Error {
constructor(reason: string, what: string, where: string) {
super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`);
this.name = "BrowserGone";
}
}
function fmtMs(ms: number): string {
return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`;
}
interface Pending {
what: string;
where: string;
ms: number;
startedAt: number;
abandon: (e: Error) => void;
}
/** Everything currently being waited on, so a loss can name every casualty at once. */
const pending = new Set<Pending>();
/** Set once the run has lost the thing every wait depends on. */
let lost: string | null = null;
/**
* Where a wait sits, as the chain of waits enclosing it — `journey X alice to sign in`.
*
* Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames
* across `await` boundaries: measured 2026-08-11, a `within` called from an async function
* reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site
* is silently wrong exactly when it is needed. The enclosing chain is better anyway — a
* reader wants "which journey, which step" far more than a line number, and journeys and
* steps are themselves bounded waits, so the chain is already there to be read.
*
* These suites are strictly sequential, which is what makes "everything else in flight" the
* same thing as "everything enclosing this". A concurrent harness would need real context
* propagation.
*/
function enclosing(): string {
const chain = [...pending].map((p) => p.what);
return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" ")}`;
}
/**
* Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared —
* reject with an error naming what was being waited for and where.
*
* The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its
* eventual rejection is absorbed instead, because a race loser surfacing as an unhandled
* rejection would crash the process minutes after the real failure was already reported.
*/
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing()));
return bounded(what, ms, enclosing(), task);
}
/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */
function bounded<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
let entry!: Pending;
const interrupted = new Promise<never>((_, reject) => {
entry = { what, where, ms, startedAt: Date.now(), abandon: reject };
timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms);
});
pending.add(entry);
const running = task();
running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection
return Promise.race([running, interrupted]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
pending.delete(entry);
});
}
/**
* Declare that the browser every wait depends on has gone, and abandon them all now.
*
* Idempotent, and one-way: once a run has lost its browser there is nothing further to
* measure, so later waits are refused rather than left to time out one by one.
*/
export function browserLost(reason: string): void {
if (lost !== null) return;
lost = reason;
console.error(`\n[e2e] ${reason}`);
if (pending.size > 0) {
console.error(` ${pending.size} wait(s) were in flight and are abandoned:`);
for (const p of pending) console.error(` - ${p.what} [${p.where}]`);
}
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;
/**
* Close a page/context/server under a deadline, reporting rather than throwing.
*
* Teardown is where a bound matters most and an exception matters least: the verdict is
* already decided, so a close that never returns must not be what the run dies of. This is
* the exact shape of the observed hang — `BrowserContext.close()` on a browser that had
* already exited, inside a `finally`, swallowing the summary that was on its way out.
*
* Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when
* closing matters most. Skipping it there would leave the Chromium processes of a failed
* run alive, and the next run would inherit them.
*/
export async function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void> {
try {
await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => {
await close();
});
} catch (e) {
console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`);
}
}
/**
* Arm the suite's own wall clock. On expiry, name every wait still in flight and exit.
*
* The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It
* reports before it dies, because "the run was killed" is the uninformative message that
* cost the hours this module exists to stop spending.
*
* `unref`ed, so a healthy run is never held open by its own watchdog.
*
* `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. (A suite built on `report.ts` passes its `finish`.)
*/
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
const startedAt = Date.now();
const timer = setTimeout(() => {
console.error(
`\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` +
" This is a HANG, not a verdict.",
);
if (pending.size === 0) {
console.error(
" Nothing was inside a bounded wait, so the block is in unbounded code: " +
"wrap the step it stopped at with `within(...)`.",
);
} else {
console.error(` Waits still in flight (${pending.size}):`);
for (const p of pending) {
console.error(
` - ${p.what}${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`,
);
}
}
console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`);
// A suite that can still say what it did and did not verify must be allowed to say it —
// otherwise the watchdog, whose whole purpose is to replace a silent kill with a report,
// produces its own silent kill. `thenReport` is expected to exit; the line below is the
// fallback for a caller that has nothing to report.
if (thenReport !== undefined) thenReport();
process.exit(1);
}, ms);
timer.unref?.();
}
/**
* Playwright's per-context defaults, set explicitly so the bound on every locator action and
* navigation is a decision in this file rather than a library default nobody looked up.
*
* The value is Playwright's own 30s, deliberately: raising it to 120s was tried on
* 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also
* how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can
* re-render under a click ("element was detached from the DOM, retrying"), and at 120s that
* flake took two minutes to surface instead of thirty seconds. Every action and navigation
* here already had a bound; the waits that had NONE are the ones this module wraps
* (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout.
*/
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)";
}
+92
View File
@@ -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";
@@ -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<string | null> {
// 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<string | null> {
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;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* What each bounded operation ACTUALLY takes — the measurement every bound is sized from.
*
* ── Why a bound needs its measurement kept beside it ─────────────────────────
* A bare number teaches nothing and rots in silence. "180 seconds" cannot be judged: is it
* ten times the normal duration, or a hundred? Only one of those is a bound; the other is a
* hang dressed up as one. The suite this module serves had a sign-in bounded at 3 minutes
* for an operation that measures 1.4s, so its single job — turning a hang into a named
* failure fast — was done fifty times too slowly to be worth anything.
*
* So every bound in these harnesses is written as `measured normal → bound → margin`, and
* this module is how the "measured normal" half is obtained and re-obtained. Run any suite
* with `E2E_TIMINGS=1` and it prints, at the end, what each named operation took and how
* much headroom its bound still has. A future reader who suspects a number has gone stale
* does not have to believe this file's comments: they can re-run the measurement.
*
* Passive by default — a `Date.now()` per wait, and nothing printed unless asked.
*/
/** One observation of one named operation. */
interface Sample {
readonly ms: number;
/** Sizing a bound from a FAILED attempt would size it from the bound itself. */
readonly ok: boolean;
/** The bound in force, so the report can show the headroom rather than make one guess it. */
readonly bound: number;
}
const samples = new Map<string, Sample[]>();
export function record(what: string, ms: number, ok: boolean, bound: number): void {
const seen = samples.get(what);
if (seen === undefined) samples.set(what, [{ ms, ok, bound }]);
else seen.push({ ms, ok, bound });
}
/**
* Run a wait under `bound`, recording what it took under the stable name `what`.
*
* The bound is handed TO the task rather than raced against it, deliberately: Playwright's
* own timeout reports the call log ("waiting for locator(…)"), and a race would replace that
* with a message naming only the enclosure. What this adds is the measurement and a stable
* name — not a second, competing deadline.
*
* The name must be stable across runs (no identifiers, no ports) or the table fragments into
* one row per run and measures nothing.
*/
export async function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T> {
const startedAt = Date.now();
let ok = false;
try {
const out = await task(bound);
ok = true;
return out;
} finally {
record(what, Date.now() - startedAt, ok, bound);
}
}
/** Whether the caller asked for the table. */
export function timingsWanted(): boolean {
return (process.env.E2E_TIMINGS ?? "") !== "";
}
function fmt(ms: number): string {
return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`;
}
/**
* Print what was measured: per operation, the healthy observations and the headroom its
* bound has over the SLOWEST of them.
*
* Failed attempts are counted but excluded from the statistics, because an operation that
* hit its bound measures the bound and not the operation — feeding that back into the sizing
* is how a bound ratchets upward for ever, one bad run at a time.
*/
export function printTimings(): void {
if (samples.size === 0) {
console.log("\n── measured durations ── nothing was recorded.");
return;
}
const rows = [...samples.entries()].map(([what, all]) => {
const good = all.filter((s) => s.ok).map((s) => s.ms).sort((a, b) => a - b);
const bound = all[all.length - 1]!.bound;
const failed = all.length - good.length;
return {
what,
n: good.length,
min: good.length === 0 ? null : good[0]!,
max: good.length === 0 ? null : good[good.length - 1]!,
bound,
failed,
};
});
const width = Math.max(...rows.map((r) => r.what.length), 9);
console.log("\n── measured durations (E2E_TIMINGS) ─────────────────────────────────────────");
console.log(
` ${"operation".padEnd(width)} ${"n".padStart(3)} ${"min".padStart(6)} ${"max".padStart(6)}` +
` ${"bound".padStart(6)} headroom failed`,
);
for (const r of rows) {
const headroom = r.max === null || r.max === 0 ? "—" : `${(r.bound / r.max).toFixed(0)}x`;
console.log(
` ${r.what.padEnd(width)} ${String(r.n).padStart(3)} ` +
`${(r.min === null ? "—" : fmt(r.min)).padStart(6)} ${(r.max === null ? "—" : fmt(r.max)).padStart(6)} ` +
`${fmt(r.bound).padStart(6)} ${headroom.padStart(8)} ${r.failed === 0 ? "" : String(r.failed)}`,
);
}
console.log(
" (statistics are over SUCCESSFUL attempts only: a wait that hit its bound measures\n" +
" the bound, and sizing the next bound from it ratchets upward for ever.)",
);
}
+186
View File
@@ -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 `<button>`/`<a>` whose trimmed text matches. */
| { readonly kind: "rendered-control"; readonly matches: TextPattern }
/** The page's RENDERED prose matches — the one test that has to read words. */
| { readonly kind: "page-text"; readonly matches: TextPattern }
/** Whatever is left. Must be the last entry, and there must be one. */
| { readonly kind: "otherwise" };
/** What moves the flow on from a screen. */
export type ScreenResponse =
| { readonly kind: "click"; readonly what: string; readonly selector: string }
| { readonly kind: "click-text"; readonly what: string; readonly text: string }
/** Fill the run's wallet password and submit it. The password is never described here —
* it belongs to the run, not to the pages. */
| { readonly kind: "submit-password"; readonly what: string; readonly selector: string }
/** Nothing to do but let it become something else. */
| { readonly kind: "wait" };
export interface BrokerScreenSpec {
readonly screen: BrokerScreen;
readonly signature: ScreenSignature;
readonly answer: ScreenResponse;
/** Terminal: reaching it ends the crossing with a failure rather than an action. */
readonly terminal?: true;
}
/**
* The inventory, IN THE ORDER IT IS TESTED — and the order is load-bearing, not cosmetic.
*
* Each screen is identified by the signature that the screens BEFORE it do not have.
* Visibility is checked by measured box rather than by presence, because the auth
* application HIDES its whole login UI (`#app` gets `display:none`) instead of removing it
* once the wallet is open — a presence test would keep reporting `wallet-list` on a page
* that has already logged in.
*/
export const BROKER_SCREENS: readonly BrokerScreenSpec[] = [
{
screen: "password",
signature: { kind: "rendered", selectors: ["#password-input", 'input[type="password"]'] },
answer: { kind: "submit-password", what: "the password", selector: "#password-input, input[type='password']" },
},
{
screen: "wallet-list",
signature: { kind: "rendered", selectors: [".wallet-box"] },
// The BOX, not its caption: the caption only renders for a wallet that carries a
// password, and the box is the thing with `role="button"` either way.
answer: { kind: "click", what: "this run's wallet", selector: ".wallet-box" },
},
{
screen: "choose-broker",
signature: { kind: "rendered", selectors: ['[role="menuitem"]'] },
answer: { kind: "click", what: "the first broker in the list", selector: '[role="menuitem"]' },
},
{
screen: "login-offered",
signature: { kind: "rendered-control", matches: { source: "^(login|anmelden)$", flags: "i" } },
answer: { kind: "click-text", what: 'the "Login" button', text: "Login" },
},
{
screen: "error",
signature: { kind: "page-text", matches: { source: "An error occurred|Invalid request", flags: "i" } },
answer: { kind: "wait" },
terminal: true,
},
{
screen: "working",
signature: { kind: "otherwise" },
answer: { kind: "wait" },
},
];
+140
View File
@@ -0,0 +1,140 @@
/**
* Browser profiles — one per run, never shared, never inherited.
*
* ── Why a run owns its profile instead of borrowing a shared one ─────────────
* A run mints its own physical NextGraph user and must not inherit the previous run's. That
* discipline is not an optimisation: a wallet reused across runs ACCUMULATES — every run
* leaves behind the identities and documents it created, nothing removes them, and a cold
* resynchronisation is O(the user's size). A wallet kept for a month took 286 s on a single
* sync step against 250 s a week earlier, and the drift was invisible because it was never
* measured against a stable baseline. A fresh user per run makes that duration comparable
* from one run to the next instead of a number that only ever grows.
*
* The obvious way to get a fresh user is to WIPE a profile at a fixed path — which is what
* this harness used to do, and it is why it needed a lock. A wipe destroys a profile that
* another run may be using, so runs had to be serialised, and a suite belonging to a
* consuming application — run from its own checkout, against the same broker — collided with
* ours exactly as two of ours would, invisibly to both. The lock could never have fixed that:
* it guarded one repository's idea of a path.
*
* A directory of its own removes the problem rather than exporting it. There is nothing to
* serialise, concurrent runs are independent by construction, and "one physical user per run,
* never reused" stops being a rule anyone can forget — a directory that did not exist a
* moment ago cannot hold a previous run's user.
*
* What the profile still IS, and must remain: persistent for the WHOLE run. A run opens
* several browser contexts over it in sequence (a reconnection is exactly that), and the
* contracts about reconnecting faithfully and not forking an account are checks on that
* persistence.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/** A profile directory this run owns and will remove. */
export interface RunProfile {
/** The directory to launch a persistent context on. */
readonly dir: string;
/** What it is for, as it appears in logs. */
readonly purpose: string;
/**
* Kill whatever still holds it, then remove it. Idempotent, and also run automatically when
* the process leaves (see below), so a killed run cleans up after itself.
*/
discard(): void;
}
const live = new Set<RunProfile>();
let leavingHandlersInstalled = false;
/** Is that process still alive? Signal 0 tests for existence without touching it. */
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
// EPERM means it exists and is someone else's — still alive, still holding it.
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* The Chromium still holding `dir`, if any. Chromium names the holder itself: `SingletonLock`
* is a symlink to `<host>-<pid>`.
*/
function holderOf(dir: string): number | null {
let target: string;
try {
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
} catch {
return null; // no lock, nothing holding it
}
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
return Number.isInteger(pid) && pid > 0 && isAlive(pid) ? pid : null;
}
/**
* A profile directory of this run's own, under the system temp dir.
*
* Under the TEMP dir and not the repository, deliberately: a profile holds a wallet, a wallet
* is an identity, and an identity must never end up committed. It also means two checkouts of
* the same suite cannot land on the same path.
*/
export function newRunProfile(purpose: string): RunProfile {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-profile-"));
let discarded = false;
const profile: RunProfile = {
dir,
purpose,
discard: () => {
if (discarded) return;
discarded = true;
live.delete(profile);
// A run that fails or is killed leaves its Chromium ALIVE: `BrowserContext.close()` in
// the teardown gives up after its bound, and a `kill -9` on the runner never gets there
// at all. Nothing else will ever want this directory, so the orphan cannot poison a
// later run the way it used to — but it would sit on the host's memory for ever, and a
// loaded host is how this suite manufactures its own flakiness. Each run therefore
// clears its OWN leftovers, which is the one moment where it is certainly safe.
const holder = holderOf(dir);
if (holder !== null) {
try {
process.kill(holder, "SIGKILL");
} catch {
/* gone between the check and the signal */
}
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* a temp dir the OS will collect anyway */
}
},
};
live.add(profile);
installLeavingHandlers();
return profile;
}
/**
* Discard every profile still live when the process leaves — including the ways out nobody
* plans for.
*
* `exit` covers the normal end and `process.exit()`, which is how these suites finish; the
* signal handlers cover Ctrl-C and `kill`, which is how a hung run ends. Everything here is
* synchronous, because an `exit` handler is the only thing that runs at that point.
*/
function installLeavingHandlers(): void {
if (leavingHandlersInstalled) return;
leavingHandlersInstalled = true;
process.on("exit", () => {
for (const profile of [...live]) profile.discard();
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
for (const profile of [...live]) profile.discard();
process.exit(130);
});
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* What a run REPORTS — a constant number of rows, whatever fails.
*
* ── The arithmetic is the point ──────────────────────────────────────────────
* A suite whose check TOTAL is a function of how far it got cannot be compared with itself. A
* journey that dies halfway takes its unreported checks with it and simply never mentions
* them: three runs of the same suite reported 24, 26 and 27 checks (VERIFIED 2026-08-16), and
* a moving total compares nothing. Worse, the checks that vanish are the ones nobody looks
* for — silence reads as absence, not as failure. A shrinking total even reads like a
* SMALLER problem instead of a bigger one.
*
* So every check is DECLARED before anything can fail. Read off the declaration, the
* arithmetic survives any death: every journey contributes exactly `checks.length + 1` rows
* whatever happens to it, including journeys that never ran because the setup died first. A
* difference between two runs is then always a real difference.
*
* The declaration doubles as the suite's table of contents, which is the other reason to keep
* it whole and in execution order.
*
* The related trap that made this self-perpetuating once: the checks were declared inside each
* journey, so a run that died in the SETUP — before any journey — printed `fatal:` and left,
* with no summary and nothing a previous run could be compared to.
*/
import { firstLine, within } from "./deadline";
import { printTimings, timingsWanted } from "./measure";
/** One journey and every check it reports. Declared up front; never assembled at run time. */
export interface JourneyDeclaration {
readonly name: string;
readonly checks: readonly string[];
}
/** Why a journey cannot start, or `null` when it can. */
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
/** Must name a declared journey, which is where its checks are declared. */
readonly name: string;
/**
* What this journey needs from the ones before it. A prerequisite that is provably dead is
* reported as such INSTEAD of being driven — not to spare the journey, but because driving
* a closed page answers with "Target page, context or browser has been closed", a verdict
* that names the innocent operation and hides the journey that actually broke.
*/
readonly needs?: readonly Prerequisite[];
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
/** Names the suite in its summary line, e.g. "Application e2e". */
readonly label: string;
/** Every journey, in execution order, with its checks. */
readonly journeys: readonly JourneyDeclaration[];
/** The bound on ONE journey — what catches a journey that never returns. */
readonly journeyBound: number;
/**
* Asked on a journey's failure: is there a KNOWN failure mode to name instead of the
* operation that happened to be in flight? Typically `() => browserTrouble(label, ctx)`.
* Its answer is put in front of the journey's reason, never in place of it.
*/
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
/** Report a declared check. Throws if the name is not one the journey declared. */
check(name: string, ok: boolean, detail?: string): void;
/** Run one journey: bounded, isolated, unable to change the shape of the report. */
journey(spec: JourneySpec): Promise<void>;
/** Report everything this run did not get to, print the summary, and leave. */
finish(fatal: string | null): never;
}
interface Check {
name: string;
ok: boolean;
detail?: string;
}
/**
* Build the reporting for a suite from its declaration.
*
* The returned functions do not use `this`, so a caller may destructure them
* (`const { check, journey, finish } = declareSuite(...)`) and read like a test file.
*/
export function declareSuite(options: SuiteOptions): SuiteReport {
const results: Check[] = [];
/** The journeys already reported, so `finish` knows what is missing. */
const reported = new Set<string>();
/** The checks the journey in flight has DECLARED and not yet reported — `null` between
* journeys, which is what makes a stray report detectable. */
let outstanding: Set<string> | null = null;
const startedAt = Date.now();
const record = (name: string, ok: boolean, detail?: string): void => {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail === undefined ? "" : " — " + detail}`);
};
/**
* Both rules — the name must be declared, and each may be reported once — are enforced by
* throwing rather than by tolerating, because either violation silently breaks the
* arithmetic the declaration exists to fix. An undeclared name adds a row no other run has;
* a repeated one consumes a row that then reads as "not reached". A throw here fails the
* journey it happens in and says exactly what is wrong with it, which is a harness bug
* reported the same way as any other failure.
*/
const check = (name: string, ok: boolean, detail?: string): void => {
if (outstanding === null) {
throw new Error(`[${options.label}] the check ${JSON.stringify(name)} was reported outside any journey`);
}
if (!outstanding.delete(name)) {
throw new Error(
`[${options.label}] the check ${JSON.stringify(name)} was reported but its journey does not ` +
"declare it (or declares it once and reports it twice) — fix the journey's `checks` list",
);
}
record(name, ok, detail);
};
/**
* ── What "isolated" buys, and what it does not ──────────────────────────────
* It does NOT mean a failure is absorbed — a contained failure is still a failure and is
* still counted, here as every one of the journey's declared checks plus the "ran to the
* end" row. What it means is that the journey's failure cannot take the FOLLOWING journeys'
* checks off the report, cannot leave them reporting a timeout that names the wrong suspect,
* and cannot end the run before its summary.
*
* The bound is what makes the catch honest: catching everything and recording a FAIL is
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
* and that is what three killed runs looked like from the outside.
*
* The last row, `ran to the end`, is not decoration either. Without it a journey that throws
* AFTER reporting its last check would report no failure at all, since there would be no
* unreached check left to carry the reason.
*/
const journey = async (spec: JourneySpec): Promise<void> => {
console.log(`\n── ${spec.name} ──`);
const planned = options.journeys.find((j) => j.name === spec.name);
if (planned === undefined) {
throw new Error(
`[${options.label}] the journey ${JSON.stringify(spec.name)} is not declared — add it, or fix the name`,
);
}
const declared = new Set(planned.checks);
if (declared.size !== planned.checks.length) {
throw new Error(`[${options.label}] the same check is declared twice under "${spec.name}"`);
}
reported.add(spec.name);
const journeyStartedAt = Date.now();
let why: string | null = null;
const blocked = (await Promise.all((spec.needs ?? []).map(async (needed) => needed()))).filter(
(r): r is string => r !== null,
);
if (blocked.length > 0) {
why = `it could not start: ${blocked.join("; ")}`;
console.error(` [blocked] ${why}`);
} else {
outstanding = declared;
try {
await within(`the journey "${spec.name}"`, options.journeyBound, spec.run);
} catch (e) {
why = firstLine(e);
// In full, and to stderr: the one-liner above is what the report carries, and it is
// never the whole of a driver's call log or a broker crossing's trail.
console.error(` [threw] ${String((e as Error)?.stack ?? e)}`);
// A known failure mode goes IN FRONT of the reason, never in place of it: the
// operation in flight is still worth having, it is just not the cause.
if (options.diagnose !== undefined) {
const known = await options.diagnose().catch(() => null);
if (known !== null) why = `${known} — the operation it died on: ${why}`;
}
} finally {
outstanding = null;
}
}
for (const name of declared) {
record(name, false, why === null ? "the journey ended without reporting it" : `not reached — ${why}`);
}
record(
`the journey "${spec.name}" ran to the end`,
why === null,
why ?? `${((Date.now() - journeyStartedAt) / 1000).toFixed(1)}s`,
);
};
/**
* The journeys that never ran are read off the declaration, so a run that died in its setup
* reports exactly the same number of checks as one that finished — all of them failed, and
* each saying why. "24 checks" and "27 checks" are not two results of the same suite; they
* are two different suites, and comparing them quietly compares nothing.
*/
const finish = (fatal: string | null): never => {
for (const planned of options.journeys) {
if (reported.has(planned.name)) continue;
const why = fatal === null ? "the suite ended before this journey ran" : `the suite died first: ${fatal}`;
for (const name of planned.checks) record(name, false, `not reached — ${why}`);
record(`the journey "${planned.name}" ran to the end`, false, why);
}
// The measurement every bound is sized from, on request. Printed BEFORE the summary so the
// summary stays the last line — which is what a reader and a `tail` look at.
if (timingsWanted()) printTimings();
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) {
console.log("\n── what failed ──");
for (const r of failed) console.log(` ${r.name}${r.detail === undefined ? "" : " — " + r.detail}`);
}
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
console.log(
`\n══ ${options.label} summary: ${results.length - failed.length} passed, ${failed.length} failed, ` +
`${results.length} total — ${minutes} min ══`,
);
process.exit(failed.length === 0 ? 0 : 1);
};
return { check, journey, finish };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Serving an application (or a fixture page) to the browser under test, the way a deployment
* would.
*/
import * as http from "node:http";
import type { Socket } from "node:net";
/**
* Serve `handler` on an ephemeral port, and hand back a close that CLOSES.
*
* `server.close()` alone stops the listener and then waits for every keep-alive connection
* to drain on its own — a browser that is still attached keeps the server half-alive long
* after the harness believes it gone. These suites close a server while a browser is still
* pointed at it (the wallet export does exactly that), so the sockets are tracked and
* destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the
* suite instead of on the connection nobody hung up.
*/
export function serveOnEphemeralPort(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
): Promise<{ url: string; close: () => void }> {
const server = http.createServer(handler);
const open = new Set<Socket>();
server.on("connection", (socket) => {
open.add(socket);
socket.on("close", () => open.delete(socket));
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => {
server.close();
for (const socket of open) socket.destroy();
open.clear();
},
});
});
});
}
@@ -0,0 +1,61 @@
/**
* The page that fetches a wallet's bytes — bundled and served by `exportWalletFile`.
*
* ── Why a page has to do this at all ─────────────────────────────────────────
* A wallet's bytes exist only inside the broker iframe: `wallet_get_file()` is an RPC to the
* wallet the broker holds, so nothing in Node can produce one. This page is the smallest thing
* that can ask — it opens a NextGraph session the way any application does, and exposes one
* function.
*
* It talks to `@ng-org/web` and to nothing else, deliberately: the machinery around it must
* stay usable by an application that has never heard of any particular compatibility layer.
* `init(callback, true, [])` is the shape an application writes; the broker (which loaded this
* page in its iframe) drives the connection and calls back with the session.
*/
import { ng, init } from "@ng-org/web";
/** What crosses back to Node: base64, because a `Uint8Array` does not survive `evaluate`. */
export interface ExportedWallet {
readonly walletName: string;
readonly b64: string;
readonly len: number;
}
/**
* The two calls this page needs, named. A narrow local shape rather than the SDK's own types:
* the wallet functions are not in its published surface at this version, and asserting the two
* signatures we actually use says more than widening everything.
*/
interface WalletFunctions {
get_wallets(): Promise<Record<string, unknown> | null | undefined>;
wallet_get_file(name: string): Promise<Uint8Array | ArrayLike<number>>;
}
const wallet = ng as unknown as WalletFunctions;
const state: { status: string } = { status: "connecting" };
void (async () => {
try {
await init(() => {
state.status = "connected";
}, true, []);
} catch (e) {
state.status = `error: ${e instanceof Error ? e.message : String(e)}`;
}
})();
(globalThis as unknown as { __ngWalletExport: unknown }).__ngWalletExport = {
status: (): string => state.status,
async file(): Promise<ExportedWallet> {
const wallets = await wallet.get_wallets();
const walletName = Object.keys(wallets ?? {})[0];
if (walletName === undefined) throw new Error("no wallet is open in this session");
const file = await wallet.wallet_get_file(walletName);
const bytes = file instanceof Uint8Array ? file : new Uint8Array(Array.from(file));
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
return { walletName, b64: btoa(binary), len: bytes.length };
},
};
+258
View File
@@ -0,0 +1,258 @@
/**
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
* bytes out as a `.ngw` file, and put a `.ngw` file into a browser profile.
*
* ── Why the real interface and not a shortcut ────────────────────────────────
* A wallet obtained any other way is not the one a person has. The wallet application is an
* application like any other, so this drives it: click for click, field for field. That is
* also what makes the harness notice when the flow upstream changes, instead of testing
* against a fixture that quietly stopped resembling it.
*
* The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`.
*
* ── On the fixed waits in these flows ────────────────────────────────────────
* The creation and import flows below contain a handful of `waitForTimeout` calls, each on a
* step where the wallet application offers NO observable signal that the work is finished
* (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing).
* They are inherited as-is, with their measured durations, and they are the only fixed waits
* in this package — everything else waits for a condition. They are the first thing to replace
* if the wallet application ever grows a marker to wait on.
*/
import type { BrowserContext, Page } from "playwright";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { closeQuietly, within } from "./deadline";
import { launchWatchedContext, newPage } from "./browser";
import { newRunProfile, type RunProfile } from "./profiles";
import { serveOnEphemeralPort } from "./serve";
import { setupBrokerPage } from "./broker";
import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui";
import type { ExportedWallet } from "./wallet-export-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
/**
* The whole wallet export measures ~7 s against a real broker. Bounded at 60 s ≈ 8x.
*
* It was two minutes once, and that cost a run twice over: the export hung, and the suite
* spent two full minutes reaching a verdict it could have reached in one — before dying
* without a summary, because an export runs in the SETUP, ahead of every journey.
*/
const EXPORT_MS = 60_000;
/** The export page appearing, then its session connecting. Both against a live broker. */
const EXPORT_PAGE_MS = 30_000;
const EXPORT_CONNECT_MS = 60_000;
/** A wallet's name and the password that opens it. */
export interface WalletCredentials {
readonly name: string;
readonly password: string;
}
/**
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
*
* The first unlock is not decoration: it is what bootstraps the verifier's repos from the
* broker, and a wallet that has never been unlocked is not usable by an application.
*/
export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise<void> {
const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx));
page.on("pageerror", () => {});
await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator(WALLET_CREATION.username);
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(credentials.name);
const passwordInput = page.locator(WALLET_CREATION.password);
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(credentials.password);
const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker. This is what a brand-new
// wallet does on its very first unlock.
const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet);
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator(WALLET_CREATION.passwordField);
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(credentials.password);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
}
/**
* This run's physical user: a profile of its own, and a wallet minted into it. The creation
* context is closed — the caller opens its own contexts over `profile.dir`, one at a time.
*
* One per run, never inherited from a previous one: see `profiles.ts` for why that is a
* property of the directory rather than a rule anyone has to remember.
*/
export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise<RunProfile> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("wallet-creation", profile.dir);
try {
await createWalletInContext(ctx, credentials);
} finally {
const { closeContext } = await import("./browser");
await closeContext("wallet-creation", ctx);
}
return profile;
}
/**
* The same, with the context left OPEN.
*
* For the cold-start case: the caller then opens its application in the SAME profile, i.e. the
* very first application session over a wallet that has never run one. Closing and relaunching
* would not be the same thing.
*/
export async function mintWalletProfileKeepingContext(
purpose: string,
credentials: WalletCredentials,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("fresh-wallet", profile.dir);
await createWalletInContext(ctx, credentials);
const first = ctx.pages()[0];
if (first !== undefined) await first.close().catch(() => {});
return { ctx, profile };
}
/**
* A context on an EMPTY profile: no wallet, no local repo cache.
*
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: a wallet's
* repos are on the broker but NOT in this profile, so a session over it starts with nothing
* local. The caller imports a wallet (see {@link importWalletFile}) before opening the
* application.
*/
export async function emptyProfileContext(
purpose: string,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("clean-profile", profile.dir);
return { ctx, profile };
}
/**
* Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it.
*
* After this the profile holds the wallet — but NOT the repos' local cache — so the next
* application session over it hits the broker-only cold-start.
*
* The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password
* can then be tested by reading it off its own screen and passing it here, which is the only
* way to tell that what the barrier shows is what actually opens the file. A default would
* make that step untestable: the import would succeed on a barrier showing anything at all,
* including nothing.
*/
export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void> {
await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" });
// Let the application render and attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath);
const passwordInput = page.locator(WALLET_IMPORT.passwordField).first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/** The export page, bundled once per run. */
let exportBundle: string | null = null;
function buildExportBundle(): string {
if (exportBundle !== null) return exportBundle;
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js");
const entry = path.join(__dirname, "wallet-export-page.ts");
execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, {
stdio: "pipe",
cwd: __dirname,
timeout: BUILD_MS,
});
exportBundle = fs.readFileSync(out, "utf-8");
return exportBundle;
}
/**
* Materialize the wallet held by `ctx`'s profile as a `.ngw` file at `ngwPath`, and return its
* size in bytes.
*
* Why an application's own suite needs this: a deployment that hands a wallet out — an access
* barrier with a download link, say — must be tested against a REAL wallet. Serving a
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
* so the check that the link works cannot fail for the right reason.
*/
export async function exportWalletFile(
ctx: BrowserContext,
ngwPath: string,
walletPassword: string,
): Promise<number> {
const bundle = buildExportBundle();
const html =
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
'<body><script type="module" src="/wallet-export-page.js"></script></body></html>';
const { url, close } = await serveOnEphemeralPort((req, res) => {
if (req.url === "/wallet-export-page.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
const page = await newPage("the wallet export", ctx);
page.on("pageerror", () => {});
try {
const frame = await setupBrokerPage(page, url, walletPassword);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined,
{ timeout: EXPORT_PAGE_MS },
);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected",
{ timeout: EXPORT_CONNECT_MS },
);
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of
// the two ways this harness used to hang for ever.
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
frame.evaluate(
() =>
(
window as unknown as { __ngWalletExport: { file(): Promise<ExportedWallet> } }
).__ngWalletExport.file(),
),
);
fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64"));
return exported.len;
} finally {
await closeQuietly("the wallet export page", () => page.close());
close();
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"],
"noEmit": true
},
"include": ["src"]
}