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
+11
View File
@@ -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)
+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());
}
@@ -1,13 +1,12 @@
/** /**
* Deadlines for the e2e harnesses so a wait that cannot end FAILS, named, instead of * Deadlines so a wait that cannot end FAILS, named, instead of hanging.
* hanging.
* *
* Why this module exists * Why this module exists
* A harness that hangs is worse than one that fails. A failure names a suspect and costs a * 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 * 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 * 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 * lean on have NO bound at all: `frame.evaluate()` (which is what every bridge call into a
* `run.ts` is) and `context.newPage()`. Playwright applies no timeout to either. * 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 * 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 * 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)); 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. */ /** Teardown bound: a close that has not returned in 30s is not going to. */
export const CLOSE_MS = 30_000; export const CLOSE_MS = 30_000;
@@ -169,7 +201,7 @@ export async function closeQuietly(what: string, close: () => Promise<unknown>):
* *
* `thenReport` lets a suite print its own summary before the process goes without it a run * `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 * 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 { export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
const startedAt = Date.now(); 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_ACTION_MS = 30_000;
export const CONTEXT_NAVIGATION_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;
}
+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"]
}
-911
View File
@@ -1,911 +0,0 @@
/**
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
* `@ng-eventually/polyfill`, fully separate from any consumer app's profile.
*
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
* minimal polyfill page (polyfill-entry.ts) inside the broker iframe.
*/
import { chromium, type BrowserContext, type Page, type Frame, type Locator } from "playwright";
import { execSync } from "node:child_process";
import * as http from "node:http";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Socket } from "node:net";
import {
CONTEXT_ACTION_MS,
CONTEXT_NAVIGATION_MS,
browserLost,
closeQuietly,
within,
} from "./deadline";
import { isAlive } from "./run-lock";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
/**
* Marks that a batch has already taken the physical user held in this profile.
*
* Named for the only question it is ever asked. It is WRITTEN when a wallet is created and
* READ in exactly one place — `ensureWallet`, to decide "discard this profile and mint a
* fresh user". It never gated anything on readiness, and the previous name (`.wallet-ready`)
* said it did: it cost a wrong diagnosis on 2026-08-11, where its presence was read as
* "the wallet is good to use" when it means the opposite — this user belongs to a batch
* that is over. `user` rather than `wallet` because what a batch consumes is an identity;
* the wallet is only its container (see the note on ensureWallet below).
*/
const USER_CONSUMED_MARKER = path.join(PROFILE_DIR, ".user-consumed");
export const WALLET_NAME = "ng-eventually-e2e";
export const WALLET_PASSWORD = "ng-eventually-e2e";
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
/** Launching a browser is local too — 30s is Playwright's own default, doubled. */
const LAUNCH_MS = 60_000;
/**
* Opening a page in a live browser is instant — measured 0.00.1s over a batch. Bounded at
* 10s, which is a hundred times the measurement and still fails while a reader is watching.
* Exported because the applicative suite has to know it: 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;
/**
* The whole wallet export measures ~7s against the real broker. Bounded at 60s ≈ 8x.
*
* Was two minutes, and that cost the batch of 2026-08-16 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 this runs in the SETUP, ahead of every journey.
*/
const EXPORT_MS = 60_000;
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
const LAUNCH_ARGS = [
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
"--allow-insecure-localhost",
"--disable-web-security",
];
function resolveChromePath(): string | undefined {
const p = chromium
.executablePath()
.replace("chrome-headless-shell", "chrome")
.replace("chromium_headless_shell", "chromium");
return p.includes("headless") ? undefined : p;
}
export function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
timeout: BUILD_MS,
});
}
/**
* Serve `page` and `routes` 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();
},
});
});
});
}
export function serveHarness(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
return serveOnEphemeralPort((req, res) => {
if (req.url === "/polyfill-entry.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);
}
});
}
// ── every browser this harness opens, launched and watched the same way ───────
/** 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: this suite's 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.
*/
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) => {
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 above 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.
//
// 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());
}
/**
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
* persisted in PROFILE_DIR for the duration of the batch.
*
* ── One PHYSICAL user per batch, not one forever ──────────────────────────
* This used to reuse a single wallet across every run, guarded by a ready marker. That
* made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual
* identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and
* an inbox, and they all land in the SAME physical user. Nothing ever removed them. A
* cold resynchronisation is O(the physical user's size) — which this library's own docs
* state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single
* sync step, against 250s a week earlier, and the drift was invisible because no one
* measured it.
*
* The fresh identities are not the mistake — they are what makes a batch reproducible
* (a stable inbox accumulates its past runs' deposits otherwise). The mistake was
* keeping the physical user that holds them. So: a new one per batch, which also makes
* the cold-sync duration comparable from one run to the next instead of being a number
* that only ever grows.
*
* What this does NOT change: the profile stays persistent WITHIN a batch, because
* CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and
* the absence of an account fork across it).
*/
/**
* Make sure no browser is still holding `dir`, and kill the one that is.
*
* ── Why a run has to do this ─────────────────────────────────────────────────
* 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).
* That orphan keeps the shared profile open, and the NEXT run then deletes the directory
* under it and launches a second Chromium on the same path. Chromium's process singleton
* settles that argument by having the newcomer hand over and quit — which the runner sees
* as its devtools pipe dying moments after launch (VERIFIED 2026-08-11: "Connection
* terminated while reading from pipe" 170 ms after `<launched>`).
*
* So one failure poisons every run after it, each faster than the last, and none of them is
* about the thing under test. Reclaiming the profile is what stops the cascade.
*
* Chromium names the holder itself: `SingletonLock` is a symlink to `<host>-<pid>`. And the
* run lock has already established that no LEGITIMATE run is alive — so whatever holds this
* profile is debris, and killing it is safe.
*/
async function reclaimProfile(dir: string): Promise<void> {
let target: string;
try {
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
} catch {
return; // no lock, nothing holding it
}
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid)) return;
console.warn(
`[e2e] a browser from an earlier run (pid ${pid}) still holds ${path.basename(dir)}` +
"killing it, or this run's own browser would be refused the profile and quit",
);
try {
process.kill(pid, "SIGKILL");
} catch {
return; // gone between the check and the signal
}
const deadline = Date.now() + 10_000;
while (isAlive(pid) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 200));
}
if (isAlive(pid)) {
throw new Error(
`[e2e] could not reclaim ${dir}: pid ${pid} survived SIGKILL. Nothing this run does ` +
"next would be measuring the code under test — stop and clear it by hand.",
);
}
}
export async function ensureWallet(): Promise<void> {
await reclaimProfile(PROFILE_DIR);
// Discard on the PROFILE's existence, not on the marker's.
//
// Keyed on the marker, a profile left behind by a batch that died BEFORE writing it was
// silently REUSED — precisely the opposite of the per-batch rule below, and a profile
// half-way through wallet creation makes the nextgraph.eu flow fail on a screen the code
// does not expect ("element was detached from the DOM"). Worse, it is self-perpetuating:
// the failed run writes no marker either, so every later run inherits the same wreck.
// Observed 2026-08-11. The marker now only says HOW OLD the discarded user was.
if (fs.existsSync(PROFILE_DIR)) {
const age = fs.existsSync(USER_CONSUMED_MARKER)
? `${Math.round((Date.now() - fs.statSync(USER_CONSUMED_MARKER).mtimeMs) / 60000)} min old`
: "left by a batch that did not finish";
console.log(
`[e2e] discarding the previous batch's wallet (${age}) — ` +
"a physical user is per-batch, see ensureWallet",
);
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
}
console.log("[e2e] creating this batch's wallet on nextgraph.eu...");
fs.mkdirSync(PROFILE_DIR, { recursive: true });
const ctx = await launchWatchedContext("wallet-creation", PROFILE_DIR);
const page = ctx.pages()[0] || (await newPage("the wallet creation flow", ctx));
page.on("pageerror", () => {});
try {
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(WALLET_NAME);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker.
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
console.log("[e2e] dedicated lib wallet created + bootstrapped");
} finally {
await closeContext("wallet-creation", ctx);
}
fs.writeFileSync(USER_CONSUMED_MARKER, new Date().toISOString());
}
export async function launchWalletContext(label = "wallet"): Promise<BrowserContext> {
return launchWatchedContext(label, PROFILE_DIR);
}
/**
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
* context, without a `.user-consumed` marker and WITHOUT tearing the context down.
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
*
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
* but the context stays OPEN and is returned (with its dir) so the caller can then
* open the SDK page in the SAME profile — i.e. the very first app session over a
* wallet that has never run the app. Caller cleans up ctx + dir.
*/
export async function createFreshWalletContext(): Promise<{
ctx: BrowserContext;
dir: string;
name: string;
}> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const ctx = await launchWatchedContext("fresh-wallet", dir);
const page = ctx.pages()[0] || (await newPage("the fresh wallet creation flow", ctx));
page.on("pageerror", () => {});
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(name);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { 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("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
await page.close().catch(() => {});
return { ctx, dir, name };
}
/**
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
* a session over it starts with an empty `self.repos`. Caller must import the wallet
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
* and the dir so the caller can clean it up.
*/
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
const ctx = await launchWatchedContext("clean-profile", dir);
return { ctx, dir };
}
/**
* Materialize THIS batch's wallet as a `.ngw` file at `ngwPath`, and return its size.
*
* A wallet's bytes exist only inside the broker iframe — `ng.wallet_get_file()` is an RPC
* to the wallet the broker holds — so nothing in Node can produce one. The harness page
* already exposes the call (`polyfill-entry.ts`, `exportWalletFile`), and `run.ts` uses it
* for the clean-profile cold-start; this wraps the same round-trip for callers that hold
* no harness frame of their own.
*
* Why the APPLICATIVE suite needs it: the access gate HANDS A WALLET FILE OUT. Serving a
* placeholder there would make the download step a decoration — the file has to be a real
* wallet, or importing it cannot let anybody in.
*/
export async function exportWalletNgw(ctx: BrowserContext, ngwPath: string): Promise<number> {
buildBundle();
const { url, close } = await serveHarness();
const page = await newPage("the wallet export", ctx);
page.on("pageerror", () => {});
try {
const frame = await setupBrokerPage(page, url);
await frame.waitForFunction(
() => (window as unknown as { __sdk?: unknown }).__sdk !== undefined,
{ timeout: 30000 },
);
await frame.waitForFunction(
() => (window as unknown as { __sdk: { status(): string } }).__sdk.status() === "connected",
{ timeout: 60000 },
);
// `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. The whole export measures ~7s.
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
frame.evaluate(() =>
(
window as unknown as {
__sdk: { exportWalletFile(): Promise<{ walletName: string; b64: string; len: number }> };
}
).__sdk.exportWalletFile(),
),
);
fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64"));
return exported.len;
} finally {
await closeQuietly("the wallet export page", () => page.close());
close();
}
}
/**
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
* this the profile holds the wallet — but NOT the repos' local cache — so the next
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
*
* The password is a PARAMETER, defaulting to the batch wallet's. The applicative suite
* reads it off the access gate's own screen and passes it here — which is the only way a
* test can tell that what the barrier DISPLAYS is what actually opens the file. Hard-coding
* it here would make that step untestable: the import would succeed on a barrier showing
* anything at all, including nothing.
*/
export async function importWalletViaFile(
page: Page,
ngwPath: string,
password: string = WALLET_PASSWORD,
): Promise<void> {
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles('input[type=file]', ngwPath);
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
*/
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
return completeBrokerLogin(page, appUrl);
}
// ── the broker's sign-in, as a state machine ─────────────────────────────────
//
// ── The defect this replaces, and why it looked like the network ─────────────
// The previous 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 `[data-testid="who"]`
// on a page that has no such element, 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
// `isVisible({ timeout: 2000 })`, 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 any more. 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.
/**
* One bound for the whole ceremony — the screens, the clicks, and the application's frame
* attaching. Measured 1.32.8s for an actor and 1.7s on a cold profile's barrier passage
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45s ≈ 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 {@link 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, which is the failure mode `notebook.ts` documents at length.
*/
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 15s 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;
/**
* The distinct screens this flow can be on. VERIFIED 2026-08-14 against the live pages
* unless noted; the upstream source is `nextgraph-rs` (`infra/ngnet/redir`, and
* `engine/broker/auth` + `app/ui-common`), read but never modified.
*
* - `choose-broker` — the redirect page with MORE than one broker to pick from. Not
* observed here (this host resolves to a single broker, which auto-selects), so it is
* handled 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.
* - `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 app frame is watched
* separately rather than inferred from the screen.
* - `error` — the broker said no ("An error occurred", "Invalid request"). Terminal.
*/
type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list" | "password" | "working" | "error";
/**
* 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. 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.
*
* The order of the tests is the load-bearing part: each screen is identified by the
* signature that the screens BEFORE it do not have. Visibility is checked by measured
* size rather than by presence, because the auth app 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.
*/
function readBrokerScreen(previous: string | null): 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;
if (shown(document.querySelector("#password-input")) || shown(document.querySelector('input[type="password"]'))) {
kind = "password";
} else if (shown(document.querySelector(".wallet-box"))) {
kind = "wallet-list";
} else if (shown(document.querySelector('[role="menuitem"]'))) {
kind = "choose-broker";
} else {
const entry = Array.from(document.querySelectorAll("button, a")).find((el) =>
/^(login|anmelden)$/i.test((el.textContent ?? "").trim()),
);
if (shown(entry ?? null)) {
kind = "login-offered";
} else {
// `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 mentioned above would otherwise answer for a page that is not showing it.
const visible = document.body === null ? "" : document.body.innerText;
kind = /An error occurred|Invalid request/i.test(visible) ? "error" : "working";
}
}
return kind === previous ? false : kind;
}
/**
* The application's frame: a SUB-frame whose URL is on the application's own origin.
*
* Both halves matter. `startsWith(origin)` rather than `includes(host)` is what stops the
* broker's own pages from answering — they carry the application's address as a query
* parameter, and that is the whole defect this file used to have. 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, { 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. 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. */
async function actOnBrokerScreen(page: Page, screen: BrokerScreen): 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]}`;
}
};
switch (screen) {
case "choose-broker":
return click("the first broker in the list", page.locator('[role="menuitem"]'));
case "login-offered":
return click('the "Login" button', page.getByText("Login", { exact: true }));
case "wallet-list":
// 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.
return click("this batch's wallet", page.locator(".wallet-box"));
case "password": {
const field = page.locator("#password-input, input[type='password']").first();
try {
await field.fill(WALLET_PASSWORD, { timeout: CLICK_MS });
await field.press("Enter", { timeout: CLICK_MS });
return "filled the password and submitted it";
} catch (e) {
return `could NOT submit the password: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
}
case "working":
case "error":
return null;
}
}
/**
* Why the sign-in did not get where it was going — with enough on it to skip the guessing.
*
* What today's version said was "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.
*/
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);
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` +
` 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 broker's sign-in
* 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, inside `init()`, once the identity is settled — and a
* journey that walks a first-time user through the 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, `?ng-id=` included — i.e. it would
* quietly substitute the suite's path for the one under test.
*
* ── The second actor is a BRANCH, not a fallthrough ──────────────────────────
* VERIFIED 2026-08-14, three consecutive sign-ins in one browser context: the first is
* asked for a password, the second and third are NOT. The wallet is broadcast between the
* broker origin's tabs over a `BroadcastChannel` named `ng_wallet`, so by the time a
* second actor reaches the wallet list, its wallet is already in `opened_wallets` and
* selecting it logs straight in (`ui-common/src/routes/WalletLogin.svelte`, the
* `$opened_wallets[selected]` path). `password` is therefore a screen that may simply
* never appear, and the machine below neither expects nor requires it — it answers what
* is on screen. The previous 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): 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}`);
if (screen === "error") {
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 actOnBrokerScreen(page, screen);
if (did !== null) trail.push(` ${did}`);
}
} finally {
watcher.stop();
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* What is POLYFILL-SPECIFIC in these suites' setup: the harness page and the wallet this
* repository's runs use.
*
* Everything generic — the wallet lifecycle, the broker crossing, profiles, bounds, the report
* shape, the recognition of the known failure modes — lives in `ng-e2e-helpers`, which knows
* nothing about this package and must keep knowing nothing about it: the polyfill is designed
* to DISAPPEAR at migration, and that machinery talks about NextGraph itself, so it outlives
* it. What is left here is the two things that genuinely belong to the polyfill: the page that
* exposes its surface to a browser (`polyfill-entry.ts`), and the name of the wallet its runs
* mint.
*/
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
mintWalletProfile,
serveOnEphemeralPort,
type RunProfile,
type WalletCredentials,
} from "ng-e2e-helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* The wallet every suite in this package mints for its own run.
*
* A NAME, not an identity that survives: each run gets a profile of its own and mints this
* wallet into it, so two runs sharing the name share nothing else. See `ng-e2e-helpers`'
* `profiles.ts` for why one physical user per run is the rule, and why it is now a property of
* the directory rather than something a lock had to enforce.
*/
export const WALLET: WalletCredentials = {
name: "ng-eventually-e2e",
password: "ng-eventually-e2e",
};
/** This run's physical user, in a profile of its own. */
export function mintBatchWallet(suite: string): Promise<RunProfile> {
return mintWalletProfile(suite, WALLET);
}
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
export function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
timeout: BUILD_MS,
});
}
/** Serve the harness page — the polyfill's surface, reachable from Playwright as `window.__sdk`. */
export function serveHarness(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
return serveOnEphemeralPort((req, res) => {
if (req.url === "/polyfill-entry.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);
}
});
}
+65 -217
View File
@@ -37,22 +37,29 @@ import { fileURLToPath } from "node:url";
import { import {
BROKER_ROUND_TRIP_MS, BROKER_ROUND_TRIP_MS,
NEW_PAGE_MS, NEW_PAGE_MS,
PROFILE_DIR, armSuiteDeadline,
WALLET_PASSWORD, browserTrouble,
closeContext, closeContext,
closeQuietly,
completeBrokerLogin, completeBrokerLogin,
ensureWallet, declareSuite,
exportWalletNgw, emptyProfileContext,
importWalletViaFile, enclosingBound,
launchCleanProfileContext, exportWalletFile,
launchWalletContext, firstLine,
frameTrouble,
importWalletFile,
launchWatchedContext,
measured,
newPage, newPage,
serveOnEphemeralPort, serveOnEphemeralPort,
setupBrokerPage, setupBrokerPage,
} from "./broker"; within,
import { armSuiteDeadline, closeQuietly, within } from "./deadline"; type JourneyDeclaration,
import { measured, printTimings, timingsWanted } from "./measure"; type Prerequisite,
import { acquireRunLock } from "./run-lock"; type RunProfile,
} from "ng-e2e-helpers";
import { WALLET, mintBatchWallet } from "./harness-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook"); const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook");
@@ -69,7 +76,7 @@ const WALLET_PATH = "/shared-wallet.ngw";
* *
* 1. A LEAF bound — one that wraps a single wait — is sized from that wait's own MEASURED * 1. A LEAF bound — one that wraps a single wait — is sized from that wait's own MEASURED
* duration, times a margin. The measurement is written beside it, so a reader can judge * duration, times a margin. The measurement is written beside it, so a reader can judge
* whether it still holds; `E2E_TIMINGS=1` re-prints all of them (see `measure.ts`), which * whether it still holds; `E2E_TIMINGS=1` re-prints all of them (`ng-e2e-helpers`), which
* is where these numbers came from and how the next reader will replace them. A bound * is where these numbers came from and how the next reader will replace them. A bound
* fifty times the normal duration is not a bound: it is a three-minute freeze that * fifty times the normal duration is not a bound: it is a three-minute freeze that
* reports at the end what a fifteen-second one would have reported at the start. * reports at the end what a fifteen-second one would have reported at the start.
@@ -88,7 +95,7 @@ const WALLET_PATH = "/shared-wallet.ngw";
* a step's bound and the enclosure shrinks with it; that is the lever, not the enclosure. * a step's bound and the enclosure shrinks with it; that is the lever, not the enclosure.
*/ */
// `NEW_PAGE_MS` and `BROKER_ROUND_TRIP_MS` are IMPORTED from `broker.ts`, not restated here: // `NEW_PAGE_MS` and `BROKER_ROUND_TRIP_MS` are IMPORTED from `ng-e2e-helpers`, not restated here:
// both operations bound themselves there (75s = the navigation plus the ceremony), and a copy // both operations bound themselves there (75s = the navigation plus the ceremony), and a copy
// set lower would fire first and replace the ceremony's failure message — the screen it // set lower would fire first and replace the ceremony's failure message — the screen it
// recognised, the trail, every frame, the page's own text — with a sentence naming only the // recognised, the trail, every frame, the page's own text — with a sentence naming only the
@@ -148,7 +155,7 @@ const HANDOVER_MS = 30_000;
const WALLET_IMPORT_MS = 60_000; const WALLET_IMPORT_MS = 60_000;
/** Closing the wallet application's tab. Measured under 0.1s. Bounded at 15s and reported /** Closing the wallet application's tab. Measured under 0.1s. Bounded at 15s and reported
* rather than thrown, like every other close: `page.close()` carries no timeout of its own, * rather than thrown, like every other close: `page.close()` carries no timeout of its own,
* and a close that never returns is the exact shape of the hang `deadline.ts` was written * and a close that never returns is the exact shape of the hang the bounds were written
* for — this was the last one in these journeys still going unbounded. */ * for — this was the last one in these journeys still going unbounded. */
const WALLET_TAB_CLOSE_MS = 15_000; const WALLET_TAB_CLOSE_MS = 15_000;
/** Asking a live frame whether it still holds the application. A `count()` is one round-trip /** Asking a live frame whether it still holds the application. A `count()` is one round-trip
@@ -166,7 +173,7 @@ const FRAME_PROBE_MS = 10_000;
* its own steps could, and every sign-in failure in this suite would go back to reporting * its own steps could, and every sign-in failure in this suite would go back to reporting
* "bob-… to sign in" and naming none of the four things it was doing. * "bob-… to sign in" and naming none of the four things it was doing.
*/ */
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + FIRST_RENDER_MS + 10_000; const SIGN_IN_MS = enclosingBound([NEW_PAGE_MS, BROKER_ROUND_TRIP_MS, FIRST_RENDER_MS], 10_000);
/** /**
* One journey. ENCLOSING — and the one place where rule 2 above is deliberately NOT applied, * One journey. ENCLOSING — and the one place where rule 2 above is deliberately NOT applied,
* which is worth saying out loud rather than leaving as an inconsistency. * which is worth saying out loud rather than leaving as an inconsistency.
@@ -209,7 +216,7 @@ const SUITE_DEADLINE_MS = 15 * 60 * 1000;
* It doubles as the suite's table of contents, which is the other reason to keep it whole * It doubles as the suite's table of contents, which is the other reason to keep it whole
* and in execution order. * and in execution order.
*/ */
const SUITE: readonly { readonly name: string; readonly checks: readonly string[] }[] = [ const SUITE: readonly JourneyDeclaration[] = [
{ {
name: "Alice and Bob each sign in, in their own space", name: "Alice and Bob each sign in, in their own space",
checks: ["Alice signs in and the application knows who she is", "Bob signs in, in his own space"], checks: ["Alice signs in and the application knows who she is", "Bob signs in, in his own space"],
@@ -266,177 +273,24 @@ const SUITE: readonly { readonly name: string; readonly checks: readonly string[
}, },
]; ];
/** The journeys that have already been reported, so {@link finish} knows what is missing. */
const reported = new Set<string>();
/** Module level, not `main`'s local: {@link finish} reports the elapsed time on the fatal
* path too, and that path can be reached before `main` has got as far as a local. */
const suiteStartedAt = Date.now();
// ── reporting ─────────────────────────────────────────────────────────────── // ── reporting ───────────────────────────────────────────────────────────────
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
/** /**
* The checks the journey in flight has DECLARED and not yet reported — `null` between * The actors' browser, once it exists.
* journeys.
* *
* ── Why a journey declares its checks up front ─────────────────────────────── * Module level so a journey's failure can ask whether the BROWSER stopped answering before
* Because otherwise the run's check TOTAL is a function of how far it got. A journey that * blaming the operation it died on — the recognition lives in `ng-e2e-helpers`
* dies halfway takes its unreported checks with it and simply never mentions them, so three * (`known-failures.ts`), and this is the only thing it needs from here.
* runs of the same suite reported 24, 26 and 27 checks (VERIFIED 2026-08-16, runs 13) — and
* a total that moves cannot be compared to anything. Worse, the checks that vanished are the
* ones nobody looked for: silence reads as absence, not as failure.
*
* Declared, the arithmetic is fixed before the run starts. Every journey contributes exactly
* `checks.length + 1` rows whatever happens to it, so the total is a property of the SUITE
* and a difference between two runs is always a real difference.
*/ */
let outstanding: Set<string> | null = null; let actorsBrowser: BrowserContext | null = null;
function record(name: string, ok: boolean, detail?: string): void { const { check, journey, finish } = declareSuite({
results.push({ name, ok, detail }); label: "Application e2e",
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`); journeys: SUITE,
} journeyBound: JOURNEY_MS,
diagnose: async () => (actorsBrowser === null ? null : browserTrouble("actors", actorsBrowser)),
});
/**
* Report a check. Its name must be one the journey declared, and each may be reported once.
*
* Both rules 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.
*/
function check(name: string, ok: boolean, detail?: string): void {
if (outstanding === null) {
throw new Error(`[e2e/app] the check ${JSON.stringify(name)} was reported outside any journey`);
}
if (!outstanding.delete(name)) {
throw new Error(
`[e2e/app] 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);
}
/** Why a journey cannot start, or `null` when it can. See {@link actorTrouble}. */
type Prerequisite = () => Promise<string | null> | (string | null);
interface JourneySpec {
/** Must name an entry of {@link SUITE}, 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 `fill` and hides the journey that actually broke.
*/
readonly needs?: readonly Prerequisite[];
readonly run: () => Promise<void>;
}
function firstLine(e: unknown): string {
return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)";
}
/**
* One journey, isolated: bounded, and unable to change the shape of the run's report.
*
* ── 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.
*/
async function journey(spec: JourneySpec): Promise<void> {
console.log(`\n── ${spec.name} ──`);
const planned = SUITE.find((j) => j.name === spec.name);
if (planned === undefined) {
throw new Error(`[e2e/app] the journey ${JSON.stringify(spec.name)} is not in SUITE — add it, or fix the name`);
}
const declared = new Set(planned.checks);
if (declared.size !== planned.checks.length) {
throw new Error(`[e2e/app] SUITE declares the same check twice under "${spec.name}"`);
}
reported.add(spec.name);
const startedAt = 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}"`, JOURNEY_MS, spec.run);
} catch (e) {
why = firstLine(e);
// In full, and to stderr: the one-liner below is what the report carries, and it is
// never the whole of a Playwright call log or a broker login trail.
console.error(` [threw] ${String((e as Error)?.stack ?? e)}`);
} 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() - startedAt) / 1000).toFixed(1)}s`,
);
}
/**
* Report everything this run did not get to, print the summary, and leave.
*
* The journeys that never ran are read off {@link SUITE}, 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. That is the whole point of a fixed total: "24 checks" and "27 checks" are
* not two results of the same suite, they are two different suites, and comparing them
* quietly compares nothing.
*/
function finish(fatal: string | null): never {
for (const planned of SUITE) {
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 in this file 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 ? " — " + r.detail : ""}`);
}
const minutes = ((Date.now() - suiteStartedAt) / 60000).toFixed(1);
console.log(
`\n══ Application e2e summary: ${results.length - failed.length} passed, ${failed.length} failed, ` +
`${results.length} total — ${minutes} min ══`,
);
process.exit(failed.length === 0 ? 0 : 1);
}
/** /**
* A named step that is both measured and bounded, for an operation carrying no timeout of * A named step that is both measured and bounded, for an operation carrying no timeout of
@@ -531,20 +385,10 @@ interface Actor {
*/ */
async function actorTrouble(id: string, a: Actor | null): Promise<string | null> { async function actorTrouble(id: string, a: Actor | null): Promise<string | null> {
if (a === null) return `${id} never signed in`; if (a === null) return `${id} never signed in`;
if (a.page.isClosed()) return `${id}'s page has been closed`; // `[data-testid="who"]` is what "this frame still holds the application" means HERE — the
if (a.frame.isDetached()) return `${id}'s application frame is detached`; // states it can be in, and why an attached frame is not proof of anything, are the package's
// The third state, and the one that actually happens: a frame that is attached, on the // (`known-failures.ts`). Only the marker is ours.
// right URL, and holds NOTHING — what a RELOADED iframe looks like from here. VERIFIED return frameTrouble(id, a.page, a.frame, '[data-testid="who"]');
// 2026-08-16: Alice's frame reached it mid-run and the next three journeys each reported a
// 30s timeout on a different innocent selector (`selectOption`, `fill`, `click`), none of
// them naming the frame. `count()` answers 0 immediately instead of waiting for the
// element, so this probe cannot itself become the hang it exists to name.
const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () =>
a.frame.locator('[data-testid="who"]').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;
} }
/** /**
@@ -567,7 +411,7 @@ async function must(id: string, a: Actor | null): Promise<Actor> {
* ── Why the TOP-LEVEL page is the decisive part ────────────────────────────── * ── Why the TOP-LEVEL page is the decisive part ──────────────────────────────
* Because `completeBrokerLogin` returns as soon as the application's frame ATTACHES, which is * Because `completeBrokerLogin` returns as soon as the application's frame ATTACHES, which is
* not the same event as the broker having opened the wallet — it watches the frame precisely * not the same event as the broker having opened the wallet — it watches the frame precisely
* because the final screen never stops reading as `working` (`broker.ts`). So a render that * because the final screen never stops reading as `working` (`ng-e2e-helpers`). So a render that
* stalls has two very different explanations, and only the broker's own screen tells them * stalls has two very different explanations, and only the broker's own screen tells them
* apart: if the top-level page still shows a login or a wallet list, the ceremony stopped * apart: if the top-level page still shows a login or a wallet list, the ceremony stopped
* driving a flow that had not finished, and the application inside is waiting for a session * driving a flow that had not finished, and the application inside is waiting for a session
@@ -645,7 +489,7 @@ function coldFirstRender(label: string, page: Page, frame: Frame): Promise<void>
* *
* ── Why the failure path closes the page ───────────────────────────────────── * ── Why the failure path closes the page ─────────────────────────────────────
* `within` abandons a wait; it cannot CANCEL it, and nothing can cancel a browser round-trip * `within` abandons a wait; it cannot CANCEL it, and nothing can cancel a browser round-trip
* (`deadline.ts` says so). So a sign-in that outlives its bound leaves a real page still * (`ng-e2e-helpers` says so). So a sign-in that outlives its bound leaves a real page still
* walking the broker's login: clicking, filling, navigating — an actor nobody is accounting * walking the broker's login: clicking, filling, navigating — an actor nobody is accounting
* for, driving the same profile the next journey is about to drive. Closing that page is the * for, driving the same profile the next journey is about to drive. Closing that page is the
* only cancellation available, and it is what stops one journey's failure from becoming the * only cancellation available, and it is what stops one journey's failure from becoming the
@@ -683,7 +527,7 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
// journeys that load the application's own address top-level do meet the barrier, and // journeys that load the application's own address top-level do meet the barrier, and
// must: that is the side a person actually arrives on. // must: that is the side a person actually arrives on.
const frame = await measured("an actor's broker round-trip", BROKER_ROUND_TRIP_MS, () => const frame = await measured("an actor's broker round-trip", BROKER_ROUND_TRIP_MS, () =>
setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`), setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`, WALLET.password),
); );
at("back inside the broker iframe"); at("back inside the broker iframe");
await firstRender("an actor's first render", FIRST_RENDER_MS, `${id}'s sign-in`, page, frame); await firstRender("an actor's first render", FIRST_RENDER_MS, `${id}'s sign-in`, page, frame);
@@ -831,17 +675,17 @@ async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise<Ac
// ── the journeys ──────────────────────────────────────────────────────────── // ── the journeys ────────────────────────────────────────────────────────────
async function main(): Promise<void> { async function main(): Promise<void> {
// Before anything touches the shared profile: this batch is about to DELETE it (see
// `ensureWallet`), so a second run alive right now would be destroyed by this one.
acquireRunLock("the applicative suite (e2e/notebook.ts)", PROFILE_DIR);
// With `finish`, so a run that trips the wall clock still prints a summary with the same // With `finish`, so a run that trips the wall clock still prints a summary with the same
// check total as any other — the watchdog exists to replace a silent kill with a report, // check total as any other — the watchdog exists to replace a silent kill with a report,
// and exiting without one would just be a slower silent kill. // and exiting without one would just be a slower silent kill.
armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS, () => finish("the suite exceeded its wall clock")); armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS, () => finish("the suite exceeded its wall clock"));
console.log("[e2e/app] building the example application..."); console.log("[e2e/app] building the example application...");
buildApp(); buildApp();
console.log("[e2e/app] ensuring the batch wallet..."); // This run's own physical user, in a directory of its own. Nothing is shared with any other
await ensureWallet(); // run, so nothing has to be serialised against one: a suite belonging to a consuming
// application can drive the same broker at the same time without either noticing.
console.log("[e2e/app] minting this batch's wallet...");
const wallet: RunProfile = await mintBatchWallet("the applicative suite (e2e/notebook.ts)");
const t = Date.now().toString(36); const t = Date.now().toString(36);
const ALICE = `alice-${t}`; const ALICE = `alice-${t}`;
@@ -867,16 +711,17 @@ async function main(): Promise<void> {
// its profile-mates are using. Cheap to avoid, so avoided — the actors inherit // its profile-mates are using. Cheap to avoid, so avoided — the actors inherit
// nothing. Sequential contexts over the one profile dir; never two at once. // nothing. Sequential contexts over the one profile dir; never two at once.
console.log("[e2e/app] exporting the wallet the barrier hands out..."); console.log("[e2e/app] exporting the wallet the barrier hands out...");
const exportCtx = await launchWalletContext("wallet-export"); const exportCtx = await launchWatchedContext("wallet-export", wallet.dir);
let walletSize = 0; let walletSize = 0;
try { try {
walletSize = await exportWalletNgw(exportCtx, sharedWalletFile); walletSize = await exportWalletFile(exportCtx, sharedWalletFile, WALLET.password);
} finally { } finally {
await closeContext("wallet-export", exportCtx); await closeContext("wallet-export", exportCtx);
} }
ctx = await launchWalletContext("actors"); ctx = await launchWatchedContext("actors", wallet.dir);
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD); actorsBrowser = ctx;
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET.password);
closeServer = served.close; closeServer = served.close;
const url = served.url; const url = served.url;
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`); console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`);
@@ -1009,7 +854,7 @@ async function main(): Promise<void> {
run: async () => { run: async () => {
const newcomer = `newcomer-${t}`; const newcomer = `newcomer-${t}`;
const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw"); const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw");
const fresh = await launchCleanProfileContext(); const fresh = await emptyProfileContext("a first-time visitor");
// `page` exists for the `finally`; `visitor` is the same page as a non-null local, so // `page` exists for the `finally`; `visitor` is the same page as a non-null local, so
// the body reads without an assertion at every use. // the body reads without an assertion at every use.
let page: Page | null = null; let page: Page | null = null;
@@ -1070,7 +915,7 @@ async function main(): Promise<void> {
gate.locator('a[target="_blank"]').click(), gate.locator('a[target="_blank"]').click(),
]); ]);
await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () =>
importWalletViaFile(walletPage, downloaded, password), importWalletFile(walletPage, downloaded, password),
); );
await closeQuietly("the wallet application's tab", () => await closeQuietly("the wallet application's tab", () =>
within("the wallet application's tab to close", WALLET_TAB_CLOSE_MS, () => walletPage.close()), within("the wallet application's tab to close", WALLET_TAB_CLOSE_MS, () => walletPage.close()),
@@ -1089,7 +934,7 @@ async function main(): Promise<void> {
).catch(() => {}); ).catch(() => {});
check("the application hands the page to the broker itself", /nextgraph\./.test(visitor.url()), visitor.url()); check("the application hands the page to the broker itself", /nextgraph\./.test(visitor.url()), visitor.url());
const frame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => const frame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () =>
completeBrokerLogin(visitor, url), completeBrokerLogin(visitor, url, WALLET.password),
); );
check( check(
"the application comes back inside the broker iframe", "the application comes back inside the broker iframe",
@@ -1127,7 +972,7 @@ async function main(): Promise<void> {
} finally { } finally {
if (page) await closeQuietly("the newcomer's page", () => page!.close()); if (page) await closeQuietly("the newcomer's page", () => page!.close());
await closeContext("clean-profile", fresh.ctx); await closeContext("clean-profile", fresh.ctx);
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } fresh.profile.discard();
} }
}, },
}); });
@@ -1153,7 +998,7 @@ async function main(): Promise<void> {
run: async () => { run: async () => {
const returning = `returning-${t}`; const returning = `returning-${t}`;
const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw"); const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw");
const fresh = await launchCleanProfileContext(); const fresh = await emptyProfileContext("a first-time visitor");
let first: Page | null = null; let first: Page | null = null;
let again: Page | null = null; let again: Page | null = null;
const startedAtJourney = Date.now(); const startedAtJourney = Date.now();
@@ -1205,7 +1050,7 @@ async function main(): Promise<void> {
]); ]);
at("first visit: the wallet application is open in its own tab"); at("first visit: the wallet application is open in its own tab");
await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () =>
importWalletViaFile(walletPage, downloaded, password), importWalletFile(walletPage, downloaded, password),
); );
at("first visit: the wallet is imported on this device"); at("first visit: the wallet is imported on this device");
await closeQuietly("the wallet application's tab", () => await closeQuietly("the wallet application's tab", () =>
@@ -1223,7 +1068,7 @@ async function main(): Promise<void> {
).catch(() => {}); ).catch(() => {});
at("first visit: handed over to the broker"); at("first visit: handed over to the broker");
const firstFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => const firstFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () =>
completeBrokerLogin(firstVisit, url), completeBrokerLogin(firstVisit, url, WALLET.password),
); );
await coldFirstRender("returning-first-visit", firstVisit, firstFrame); await coldFirstRender("returning-first-visit", firstVisit, firstFrame);
// A note, so the second visit can be shown to land in the SAME space rather than // A note, so the second visit can be shown to land in the SAME space rather than
@@ -1273,7 +1118,7 @@ async function main(): Promise<void> {
returnVisit.url(), returnVisit.url(),
); );
const backFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => const backFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () =>
completeBrokerLogin(returnVisit, url), completeBrokerLogin(returnVisit, url, WALLET.password),
); );
at("return visit: back inside the broker iframe"); at("return visit: back inside the broker iframe");
await coldFirstRender("returning-second-visit", returnVisit, backFrame); await coldFirstRender("returning-second-visit", returnVisit, backFrame);
@@ -1303,7 +1148,7 @@ async function main(): Promise<void> {
if (first) await closeQuietly("the first visit's page", () => first!.close()); if (first) await closeQuietly("the first visit's page", () => first!.close());
if (again) await closeQuietly("the return visit's page", () => again!.close()); if (again) await closeQuietly("the return visit's page", () => again!.close());
await closeContext("returning-visitor", fresh.ctx); await closeContext("returning-visitor", fresh.ctx);
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } fresh.profile.discard();
} }
}, },
}); });
@@ -1313,14 +1158,17 @@ async function main(): Promise<void> {
if (ctx) await closeContext("actors", ctx); if (ctx) await closeContext("actors", ctx);
closeServer?.(); closeServer?.();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
// This run's physical user goes with it — explicitly here, and again on the way out for
// the runs that never reach a `finally`.
wallet.discard();
} }
finish(null); finish(null);
} }
main().catch((e) => { main().catch((e) => {
// Anything the journeys did not catch — a refused run lock, a wallet export that hung, a // Anything the journeys did not catch — a wallet that could not be minted, an export that
// browser lost during setup. Reported through the SAME summary as everything else rather // hung, a browser lost during setup. Reported through the SAME summary as everything else
// than as a bare `fatal:`, because a run that prints no summary is a run whose numbers // than as a bare `fatal:`, because a run that prints no summary is a run whose numbers
// cannot be compared with any other. VERIFIED 2026-08-16: the export hung and this path // cannot be compared with any other. VERIFIED 2026-08-16: the export hung and this path
// printed a stack and left, so the batch reported zero checks out of zero. // printed a stack and left, so the batch reported zero checks out of zero.
@@ -23,20 +23,15 @@
* bun run e2e/reactivity-doc-subscribe.ts * bun run e2e/reactivity-doc-subscribe.ts
* (or `bun run test:e2e:reactivity` from packages/polyfill) * (or `bun run test:e2e:reactivity` from packages/polyfill)
* *
* It reuses the exact real-broker plumbing of run.ts / broker.ts: the dedicated lib * It reuses the exact real-broker plumbing of run.ts (`ng-e2e-helpers`): the dedicated lib
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on * wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
* the SAME persistent wallet context — a second concurrent verifier session on one * the SAME persistent wallet context — a second concurrent verifier session on one
* shared wallet (as faithfulReconnect does) — and writes from it. * shared wallet (as faithfulReconnect does) — and writes from it.
*/ */
import type { Frame, Page, BrowserContext } from "playwright"; import type { Frame, Page, BrowserContext } from "playwright";
import { import { launchWatchedContext, setupBrokerPage, type RunProfile } from "ng-e2e-helpers";
buildBundle, import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
serveHarness,
ensureWallet,
launchWalletContext,
setupBrokerPage,
} from "./broker";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = []; const results: Check[] = [];
@@ -92,7 +87,7 @@ async function openSession(
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t); if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`); else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
}); });
const frame = await setupBrokerPage(page, url); const frame = await setupBrokerPage(page, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000, timeout: 60000,
@@ -110,14 +105,14 @@ const STATE_TIMEOUT_MS = 20000;
async function main(): Promise<void> { async function main(): Promise<void> {
console.log("[reactivity] building SDK page bundle..."); console.log("[reactivity] building SDK page bundle...");
buildBundle(); buildBundle();
console.log("[reactivity] ensuring dedicated lib wallet..."); console.log("[reactivity] minting this run's wallet...");
await ensureWallet(); const wallet: RunProfile = await mintBatchWallet("the reactivity suite (e2e/reactivity-doc-subscribe.ts)");
const { url, close: closeServer } = await serveHarness(); const { url, close: closeServer } = await serveHarness();
console.log(`[reactivity] harness served at ${url}`); console.log(`[reactivity] harness served at ${url}`);
let ctx: BrowserContext | null = null; let ctx: BrowserContext | null = null;
try { try {
ctx = await launchWalletContext(); ctx = await launchWatchedContext("reactivity", wallet.dir);
// ── Session A (the subscriber for both cases) ──────────────────────────── // ── Session A (the subscriber for both cases) ────────────────────────────
const A = await openSession(ctx, url, "A"); const A = await openSession(ctx, url, "A");
@@ -263,6 +258,7 @@ async function main(): Promise<void> {
} finally { } finally {
try { if (ctx) await ctx.close(); } catch { /* ignore */ } try { if (ctx) await ctx.close(); } catch { /* ignore */ }
closeServer(); closeServer();
wallet.discard();
} }
// ── Determination summary (not a pass/fail gate — this is a probe) ────────── // ── Determination summary (not a pass/fail gate — this is a probe) ──────────
+15 -8
View File
@@ -21,9 +21,9 @@
* Run: `bun run e2e/repro-fresh-wallet.ts`. * Run: `bun run e2e/repro-fresh-wallet.ts`.
*/ */
import * as fs from "node:fs";
import type { Frame, Page, BrowserContext } from "playwright"; import type { Frame, Page, BrowserContext } from "playwright";
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker"; import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile } from "ng-e2e-helpers";
import { WALLET, buildBundle, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = []; const results: Check[] = [];
@@ -47,13 +47,20 @@ async function main(): Promise<void> {
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)..."); console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
let ctx: BrowserContext | null = null; let ctx: BrowserContext | null = null;
let dir: string | null = null; let profile: RunProfile | null = null;
let page: Page | null = null; let page: Page | null = null;
try { try {
const fresh = await createFreshWalletContext(); // A name of its own, not the batch wallet's: what this reproduction needs is a wallet
// whose private-store repo has never been opened by an application, and reusing a name
// would not give one.
const credentials = {
name: `ng-fresh-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
password: WALLET.password,
};
const fresh = await mintWalletProfileKeepingContext("the cold-start reproduction", credentials);
ctx = fresh.ctx; ctx = fresh.ctx;
dir = fresh.dir; profile = fresh.profile;
console.log(`[repro] fresh wallet: ${fresh.name}`); console.log(`[repro] fresh wallet: ${credentials.name}`);
page = await ctx.newPage(); page = await ctx.newPage();
page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("pageerror", (e) => console.error("[iframe error]", e.message));
@@ -62,7 +69,7 @@ async function main(): Promise<void> {
}); });
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)..."); console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
const frame = await setupBrokerPage(page, url); const frame = await setupBrokerPage(page, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000, timeout: 60000,
@@ -102,7 +109,7 @@ async function main(): Promise<void> {
} finally { } finally {
try { if (page) await page.close(); } catch { /* ignore */ } try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ } try { if (ctx) await ctx.close(); } catch { /* ignore */ }
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } profile?.discard();
closeServer(); closeServer();
} }
-131
View File
@@ -1,131 +0,0 @@
/**
* One run at a time over the shared wallet profile.
*
* ── Why ──────────────────────────────────────────────────────────────────────
* Both suites call `ensureWallet()`, and its first act is `fs.rmSync(PROFILE_DIR)` — it
* discards the previous batch's physical user on purpose (see `broker.ts`). Started while
* another run is alive, that deletes the profile out from under a browser which is USING
* it, and the first run then fails somewhere far from the cause, looking like a product
* defect. That has already cost several undecidable measurements: a suite blamed for a
* hang that was really a second run wiping its wallet.
*
* So the exclusion is made structural rather than remembered.
*
* ── Fail, not wait ───────────────────────────────────────────────────────────
* A second run is REFUSED, immediately, naming the holder. Queueing would be the wrong
* answer for a harness: these batches run for minutes, and a command that silently blocks
* for a quarter of an hour is the same disease as the hang this was written alongside —
* you cannot tell it from a freeze. A refusal is legible in one line and costs nothing.
*
* ── Where the file lives ─────────────────────────────────────────────────────
* Under the system temp dir, NOT inside the profile it guards: `ensureWallet` deletes that
* directory wholesale, which would erase the lock at the exact moment it is protecting
* something. Naming it after the profile's path keeps one lock per guarded profile, and
* keeps it out of the repository (nothing to gitignore, nothing to commit by accident).
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
interface LockRecord {
pid: number;
suite: string;
startedAt: string;
}
function lockPathFor(guarded: string): string {
const slug = guarded.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return path.join(os.tmpdir(), `ng-eventually-e2e-${slug}.lock`);
}
/** 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 the lock.
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
function readRecord(lockPath: string): LockRecord | null {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
if (parsed && typeof parsed === "object" && typeof (parsed as LockRecord).pid === "number") {
return parsed as LockRecord;
}
return null;
} catch {
return null;
}
}
/**
* Take the lock for `suite` over `guarded`, or throw naming who holds it.
*
* A lock left by a process that no longer exists is taken over — a run killed mid-batch
* (which is how every one of this harness's hangs ended) must not make the next one
* unrunnable. That check is on the OS's view of the pid, not on the file's age: a timeout
* would either strand a slow-but-healthy batch or hand the profile to a second run while
* the first still holds it, and both are the corruption this exists to stop.
*/
export function acquireRunLock(suite: string, guarded: string): void {
const lockPath = lockPathFor(guarded);
const record: LockRecord = { pid: process.pid, suite, startedAt: new Date().toISOString() };
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.writeFileSync(lockPath, JSON.stringify(record), { flag: "wx" });
installRelease(lockPath);
return;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
const held = readRecord(lockPath);
if (held !== null && isAlive(held.pid)) {
const ageMin = Math.round((Date.now() - Date.parse(held.startedAt)) / 60000);
throw new Error(
`[e2e] refusing to start: another e2e run holds ${guarded}.\n` +
` holder: ${held.suite} (pid ${held.pid}, started ${held.startedAt}, ${ageMin} min ago)\n` +
" Two runs share one wallet profile, and each one's setup DELETES it — so the\n" +
" second would corrupt the first. Wait for it, or stop it, then run again.\n" +
` If that process is gone, remove ${lockPath}.`,
);
}
// Nobody is behind it: a killed run's leftover. Take it over and say so.
console.warn(
`[e2e] taking over a stale run lock (${held === null ? "unreadable" : `pid ${held.pid} is gone`}) — ${lockPath}`,
);
fs.rmSync(lockPath, { force: true });
}
}
throw new Error(`[e2e] could not take the run lock at ${lockPath} (raced twice)`);
}
/**
* Release on the way out, including the ways out nobody plans for.
*
* `exit` covers the normal end and `process.exit()`, which is how both suites finish; the
* signal handlers cover Ctrl-C and `kill`, which is how a hung batch ends. A lock that
* outlives its run is only a nuisance — the takeover above clears it — but leaving one
* behind on every interrupt would make the nuisance the norm.
*/
function installRelease(lockPath: string): void {
const release = (): void => {
const held = readRecord(lockPath);
if (held !== null && held.pid !== process.pid) return; // someone else's now; leave it
try {
fs.rmSync(lockPath, { force: true });
} catch {
/* the takeover path handles whatever is left */
}
};
process.on("exit", release);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
release();
process.exit(130);
});
}
}
+27 -24
View File
@@ -17,19 +17,18 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { Frame, Page, BrowserContext } from "playwright"; import type { Frame, Page, BrowserContext } from "playwright";
import { import {
buildBundle, armSuiteDeadline,
serveHarness,
closeContext, closeContext,
ensureWallet, closeQuietly,
launchWalletContext, emptyProfileContext,
launchCleanProfileContext, importWalletFile,
importWalletViaFile, launchWatchedContext,
newPage, newPage,
setupBrokerPage, setupBrokerPage,
PROFILE_DIR, within,
} from "./broker"; type RunProfile,
import { armSuiteDeadline, closeQuietly, within } from "./deadline"; } from "ng-e2e-helpers";
import { acquireRunLock } from "./run-lock"; import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = []; const results: Check[] = [];
@@ -105,7 +104,7 @@ async function faithfulReconnect(
p.on("console", (m) => { p.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text()); if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
}); });
const frame = await setupBrokerPage(p, url); const frame = await setupBrokerPage(p, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
return { page: p, frame }; return { page: p, frame };
@@ -161,21 +160,21 @@ function assertWithinBudget(): void {
} }
async function main(): Promise<void> { async function main(): Promise<void> {
// Before anything touches the shared profile: this batch is about to DELETE it (see
// `ensureWallet`), so a second run alive right now would be destroyed by this one.
acquireRunLock("the polyfill suite (e2e/run.ts)", PROFILE_DIR);
armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS); armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS);
console.log("[e2e] building SDK page bundle..."); console.log("[e2e] building SDK page bundle...");
buildBundle(); buildBundle();
console.log("[e2e] ensuring dedicated lib wallet..."); // This batch's own physical user, in a profile directory of its own. Nothing to serialise
await ensureWallet(); // against another run: there is no shared directory left for two runs to fight over, so a
// suite from a consuming application can drive the same broker at the same time.
console.log("[e2e] minting this batch's wallet...");
const wallet: RunProfile = await mintBatchWallet("the polyfill suite (e2e/run.ts)");
const { url, close: closeServer } = await serveHarness(); const { url, close: closeServer } = await serveHarness();
console.log(`[e2e] harness served at ${url}`); console.log(`[e2e] harness served at ${url}`);
let ctx: BrowserContext | null = null; let ctx: BrowserContext | null = null;
let page: Page | null = null; let page: Page | null = null;
try { try {
ctx = await launchWalletContext("sdk-harness"); ctx = await launchWatchedContext("sdk-harness", wallet.dir);
page = await newPage("the SDK harness", ctx); page = await newPage("the SDK harness", ctx);
page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => { page.on("console", (m) => {
@@ -183,7 +182,7 @@ async function main(): Promise<void> {
}); });
console.log("[e2e] loading SDK page in broker iframe..."); console.log("[e2e] loading SDK page in broker iframe...");
const frame = await setupBrokerPage(page, url); const frame = await setupBrokerPage(page, url, WALLET.password);
// Wait for the bridge to exist + the broker session to connect. // Wait for the bridge to exist + the broker session to connect.
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
@@ -496,20 +495,20 @@ async function main(): Promise<void> {
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64")); fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
let cleanCtx: BrowserContext | null = null; let cleanCtx: BrowserContext | null = null;
let cleanDir: string | null = null; let cleanProfile: RunProfile | null = null;
let cleanPage: Page | null = null; let cleanPage: Page | null = null;
try { try {
const launched = await launchCleanProfileContext(); const launched = await emptyProfileContext("the clean-profile cold read");
cleanCtx = launched.ctx; cleanCtx = launched.ctx;
cleanDir = launched.dir; cleanProfile = launched.profile;
cleanPage = await newPage("the clean-profile session", cleanCtx); cleanPage = await newPage("the clean-profile session", cleanCtx);
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message)); cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); }); cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
// Import the SAME wallet into the empty profile (broker-only repos), then open // Import the SAME wallet into the empty profile (broker-only repos), then open
// the SDK page in a fresh broker session over it. // the SDK page in a fresh broker session over it.
await importWalletViaFile(cleanPage, ngwPath); await importWalletFile(cleanPage, ngwPath, WALLET.password);
const cleanFrame = await setupBrokerPage(cleanPage, url); const cleanFrame = await setupBrokerPage(cleanPage, url, WALLET.password);
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo"); const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
@@ -528,7 +527,7 @@ async function main(): Promise<void> {
} finally { } finally {
if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close()); if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close());
if (cleanCtx) await closeContext("clean-profile", cleanCtx); if (cleanCtx) await closeContext("clean-profile", cleanCtx);
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ } cleanProfile?.discard();
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ } try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
} }
}); });
@@ -826,6 +825,10 @@ async function main(): Promise<void> {
if (page) await closeQuietly("the SDK harness page", () => page!.close()); if (page) await closeQuietly("the SDK harness page", () => page!.close());
if (ctx) await closeContext("sdk-harness", ctx); if (ctx) await closeContext("sdk-harness", ctx);
closeServer(); closeServer();
// This run's physical user goes with it. Explicit here and also registered on the way
// out, so a run that is killed mid-batch still takes its profile — and the Chromium
// holding it — with it, instead of leaving both for a host that has to keep running.
wallet.discard();
} }
// ── Summary ─────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────