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
-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();
}
}
-218
View File
@@ -1,218 +0,0 @@
/**
* Deadlines for the e2e harnesses — so a wait that cannot end FAILS, named, instead of
* hanging.
*
* ── Why this module exists ───────────────────────────────────────────────────
* A harness that hangs is worse than one that fails. A failure names a suspect and costs a
* minute; a hang costs an hour and leaves every measurement of the session undecidable —
* was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites
* lean on have NO bound at all: `frame.evaluate()` (which is what every `sdk(...)` call in
* `run.ts` is) and `context.newPage()`. Playwright applies no timeout to either.
*
* And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away
* mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies
* INSIDE its own cleanup, after its last journey, without ever printing its summary or its
* failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness
* was killed for, three times.
*
* So: every wait that can block gets a deadline, and on expiry an error that says WHAT it
* was waiting for and WHERE — the chain of journeys and steps it sits inside (see
* {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing
* from "which wait" to "which of these thirty-two".
*
* ── Bounds are generous on purpose ───────────────────────────────────────────
* The numbers are sized from OBSERVED healthy timings with a wide margin (see each
* caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a
* bound that fires on a slow broker manufactures exactly the false diagnosis it exists to
* prevent.
*/
/** Thrown when a bounded wait outlives its deadline. */
export class DeadlineExceeded extends Error {
constructor(what: string, ms: number, where: string) {
super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`);
this.name = "DeadlineExceeded";
}
}
/**
* Thrown at every wait in flight when the browser they all depend on has gone away.
*
* Without it, a dead browser is discovered one 60-second timeout at a time — or never, on
* the waits Playwright does not bound. The suite has nothing left to measure at that
* point, so the useful thing is to say so once, immediately, and name the loss.
*/
export class BrowserGone extends Error {
constructor(reason: string, what: string, where: string) {
super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`);
this.name = "BrowserGone";
}
}
function fmtMs(ms: number): string {
return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`;
}
interface Pending {
what: string;
where: string;
ms: number;
startedAt: number;
abandon: (e: Error) => void;
}
/** Everything currently being waited on, so a loss can name every casualty at once. */
const pending = new Set<Pending>();
/** Set once the run has lost the thing every wait depends on. */
let lost: string | null = null;
/**
* Where a wait sits, as the chain of waits enclosing it — `journey X alice to sign in`.
*
* Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames
* across `await` boundaries: measured 2026-08-11, a `within` called from an async function
* reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site
* is silently wrong exactly when it is needed. The enclosing chain is better anyway — a
* reader wants "which journey, which step" far more than a line number, and journeys and
* steps are themselves bounded waits, so the chain is already there to be read.
*
* These suites are strictly sequential, which is what makes "everything else in flight" the
* same thing as "everything enclosing this". A concurrent harness would need real context
* propagation.
*/
function enclosing(): string {
const chain = [...pending].map((p) => p.what);
return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" ")}`;
}
/**
* Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared —
* reject with an error naming what was being waited for and where.
*
* The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its
* eventual rejection is absorbed instead, because a race loser surfacing as an unhandled
* rejection would crash the process minutes after the real failure was already reported.
*/
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing()));
return bounded(what, ms, enclosing(), task);
}
/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */
function bounded<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
let entry!: Pending;
const interrupted = new Promise<never>((_, reject) => {
entry = { what, where, ms, startedAt: Date.now(), abandon: reject };
timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms);
});
pending.add(entry);
const running = task();
running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection
return Promise.race([running, interrupted]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
pending.delete(entry);
});
}
/**
* Declare that the browser every wait depends on has gone, and abandon them all now.
*
* Idempotent, and one-way: once a run has lost its browser there is nothing further to
* measure, so later waits are refused rather than left to time out one by one.
*/
export function browserLost(reason: string): void {
if (lost !== null) return;
lost = reason;
console.error(`\n[e2e] ${reason}`);
if (pending.size > 0) {
console.error(` ${pending.size} wait(s) were in flight and are abandoned:`);
for (const p of pending) console.error(` - ${p.what} [${p.where}]`);
}
for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where));
}
/** Teardown bound: a close that has not returned in 30s is not going to. */
export const CLOSE_MS = 30_000;
/**
* Close a page/context/server under a deadline, reporting rather than throwing.
*
* Teardown is where a bound matters most and an exception matters least: the verdict is
* already decided, so a close that never returns must not be what the run dies of. This is
* the exact shape of the observed hang — `BrowserContext.close()` on a browser that had
* already exited, inside a `finally`, swallowing the summary that was on its way out.
*
* Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when
* closing matters most. Skipping it there would leave the Chromium processes of a failed
* run alive, and the next run would inherit them.
*/
export async function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void> {
try {
await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => {
await close();
});
} catch (e) {
console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`);
}
}
/**
* Arm the suite's own wall clock. On expiry, name every wait still in flight and exit.
*
* The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It
* reports before it dies, because "the run was killed" is the uninformative message that
* cost the hours this module exists to stop spending.
*
* `unref`ed, so a healthy run is never held open by its own watchdog.
*
* `thenReport` lets a suite print its own summary before the process goes — without it a run
* that trips this watchdog reports its waits and then vanishes, so its check count is zero
* and comparable with nothing. (`notebook.ts` passes its `finish`.)
*/
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
const startedAt = Date.now();
const timer = setTimeout(() => {
console.error(
`\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` +
" This is a HANG, not a verdict.",
);
if (pending.size === 0) {
console.error(
" Nothing was inside a bounded wait, so the block is in unbounded code: " +
"wrap the step it stopped at with `within(...)`.",
);
} else {
console.error(` Waits still in flight (${pending.size}):`);
for (const p of pending) {
console.error(
` - ${p.what}${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`,
);
}
}
console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`);
// A suite that can still say what it did and did not verify must be allowed to say it —
// otherwise the watchdog, whose whole purpose is to replace a silent kill with a report,
// produces its own silent kill. `thenReport` is expected to exit; the line below is the
// fallback for a caller that has nothing to report.
if (thenReport !== undefined) thenReport();
process.exit(1);
}, ms);
timer.unref?.();
}
/**
* Playwright's per-context defaults, set explicitly so the bound on every locator action and
* navigation is a decision in this file rather than a library default nobody looked up.
*
* The value is Playwright's own 30s, deliberately: raising it to 120s was tried on
* 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also
* how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can
* re-render under a click ("element was detached from the DOM, retrying"), and at 120s that
* flake took two minutes to surface instead of thirty seconds. Every action and navigation
* here already had a bound; the waits that had NONE are the ones this module wraps
* (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout.
*/
export const CONTEXT_ACTION_MS = 30_000;
export const CONTEXT_NAVIGATION_MS = 30_000;
+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);
}
});
}
-113
View File
@@ -1,113 +0,0 @@
/**
* What each bounded operation ACTUALLY takes — the measurement every bound is sized from.
*
* ── Why a bound needs its measurement kept beside it ─────────────────────────
* A bare number teaches nothing and rots in silence. "180 seconds" cannot be judged: is it
* ten times the normal duration, or a hundred? Only one of those is a bound; the other is a
* hang dressed up as one. The suite this module serves had a sign-in bounded at 3 minutes
* for an operation that measures 1.4s, so its single job — turning a hang into a named
* failure fast — was done fifty times too slowly to be worth anything.
*
* So every bound in these harnesses is written as `measured normal → bound → margin`, and
* this module is how the "measured normal" half is obtained and re-obtained. Run any suite
* with `E2E_TIMINGS=1` and it prints, at the end, what each named operation took and how
* much headroom its bound still has. A future reader who suspects a number has gone stale
* does not have to believe this file's comments: they can re-run the measurement.
*
* Passive by default — a `Date.now()` per wait, and nothing printed unless asked.
*/
/** One observation of one named operation. */
interface Sample {
readonly ms: number;
/** Sizing a bound from a FAILED attempt would size it from the bound itself. */
readonly ok: boolean;
/** The bound in force, so the report can show the headroom rather than make one guess it. */
readonly bound: number;
}
const samples = new Map<string, Sample[]>();
export function record(what: string, ms: number, ok: boolean, bound: number): void {
const seen = samples.get(what);
if (seen === undefined) samples.set(what, [{ ms, ok, bound }]);
else seen.push({ ms, ok, bound });
}
/**
* Run a wait under `bound`, recording what it took under the stable name `what`.
*
* The bound is handed TO the task rather than raced against it, deliberately: Playwright's
* own timeout reports the call log ("waiting for locator(…)"), and a race would replace that
* with a message naming only the enclosure. What this adds is the measurement and a stable
* name — not a second, competing deadline.
*
* The name must be stable across runs (no identifiers, no ports) or the table fragments into
* one row per run and measures nothing.
*/
export async function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T> {
const startedAt = Date.now();
let ok = false;
try {
const out = await task(bound);
ok = true;
return out;
} finally {
record(what, Date.now() - startedAt, ok, bound);
}
}
/** Whether the caller asked for the table. */
export function timingsWanted(): boolean {
return (process.env.E2E_TIMINGS ?? "") !== "";
}
function fmt(ms: number): string {
return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`;
}
/**
* Print what was measured: per operation, the healthy observations and the headroom its
* bound has over the SLOWEST of them.
*
* Failed attempts are counted but excluded from the statistics, because an operation that
* hit its bound measures the bound and not the operation — feeding that back into the sizing
* is how a bound ratchets upward for ever, one bad run at a time.
*/
export function printTimings(): void {
if (samples.size === 0) {
console.log("\n── measured durations ── nothing was recorded.");
return;
}
const rows = [...samples.entries()].map(([what, all]) => {
const good = all.filter((s) => s.ok).map((s) => s.ms).sort((a, b) => a - b);
const bound = all[all.length - 1]!.bound;
const failed = all.length - good.length;
return {
what,
n: good.length,
min: good.length === 0 ? null : good[0]!,
max: good.length === 0 ? null : good[good.length - 1]!,
bound,
failed,
};
});
const width = Math.max(...rows.map((r) => r.what.length), 9);
console.log("\n── measured durations (E2E_TIMINGS) ─────────────────────────────────────────");
console.log(
` ${"operation".padEnd(width)} ${"n".padStart(3)} ${"min".padStart(6)} ${"max".padStart(6)}` +
` ${"bound".padStart(6)} headroom failed`,
);
for (const r of rows) {
const headroom = r.max === null || r.max === 0 ? "—" : `${(r.bound / r.max).toFixed(0)}x`;
console.log(
` ${r.what.padEnd(width)} ${String(r.n).padStart(3)} ` +
`${(r.min === null ? "—" : fmt(r.min)).padStart(6)} ${(r.max === null ? "—" : fmt(r.max)).padStart(6)} ` +
`${fmt(r.bound).padStart(6)} ${headroom.padStart(8)} ${r.failed === 0 ? "" : String(r.failed)}`,
);
}
console.log(
" (statistics are over SUCCESSFUL attempts only: a wait that hit its bound measures\n" +
" the bound, and sizing the next bound from it ratchets upward for ever.)",
);
}
+65 -217
View File
@@ -37,22 +37,29 @@ import { fileURLToPath } from "node:url";
import {
BROKER_ROUND_TRIP_MS,
NEW_PAGE_MS,
PROFILE_DIR,
WALLET_PASSWORD,
armSuiteDeadline,
browserTrouble,
closeContext,
closeQuietly,
completeBrokerLogin,
ensureWallet,
exportWalletNgw,
importWalletViaFile,
launchCleanProfileContext,
launchWalletContext,
declareSuite,
emptyProfileContext,
enclosingBound,
exportWalletFile,
firstLine,
frameTrouble,
importWalletFile,
launchWatchedContext,
measured,
newPage,
serveOnEphemeralPort,
setupBrokerPage,
} from "./broker";
import { armSuiteDeadline, closeQuietly, within } from "./deadline";
import { measured, printTimings, timingsWanted } from "./measure";
import { acquireRunLock } from "./run-lock";
within,
type JourneyDeclaration,
type Prerequisite,
type RunProfile,
} from "ng-e2e-helpers";
import { WALLET, mintBatchWallet } from "./harness-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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
* 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
* 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.
@@ -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.
*/
// `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
// 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
@@ -148,7 +155,7 @@ const HANDOVER_MS = 30_000;
const WALLET_IMPORT_MS = 60_000;
/** 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,
* 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. */
const WALLET_TAB_CLOSE_MS = 15_000;
/** 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
* "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,
* 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
* 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",
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 ───────────────────────────────────────────────────────────────
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
* journeys.
* The actors' browser, once it exists.
*
* ── Why a journey declares its checks up front ───────────────────────────────
* Because otherwise the run's check TOTAL is a function of how far it got. A journey that
* dies halfway takes its unreported checks with it and simply never mentions them, so three
* 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.
* Module level so a journey's failure can ask whether the BROWSER stopped answering before
* blaming the operation it died on — the recognition lives in `ng-e2e-helpers`
* (`known-failures.ts`), and this is the only thing it needs from here.
*/
let outstanding: Set<string> | null = null;
let actorsBrowser: BrowserContext | null = null;
function record(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
const { check, journey, finish } = declareSuite({
label: "Application e2e",
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
@@ -531,20 +385,10 @@ interface Actor {
*/
async function actorTrouble(id: string, a: Actor | null): Promise<string | null> {
if (a === null) return `${id} never signed in`;
if (a.page.isClosed()) return `${id}'s page has been closed`;
if (a.frame.isDetached()) return `${id}'s application frame is detached`;
// The third state, and the one that actually happens: a frame that is attached, on the
// right URL, and holds NOTHING — what a RELOADED iframe looks like from here. VERIFIED
// 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;
// `[data-testid="who"]` is what "this frame still holds the application" means HERE — the
// states it can be in, and why an attached frame is not proof of anything, are the package's
// (`known-failures.ts`). Only the marker is ours.
return frameTrouble(id, a.page, a.frame, '[data-testid="who"]');
}
/**
@@ -567,7 +411,7 @@ async function must(id: string, a: Actor | null): Promise<Actor> {
* ── Why the TOP-LEVEL page is the decisive part ──────────────────────────────
* 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
* 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
* 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
@@ -645,7 +489,7 @@ function coldFirstRender(label: string, page: Page, frame: Frame): Promise<void>
*
* ── Why the failure path closes the page ─────────────────────────────────────
* `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
* 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
@@ -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
// must: that is the side a person actually arrives on.
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");
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 ────────────────────────────────────────────────────────────
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
// 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.
armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS, () => finish("the suite exceeded its wall clock"));
console.log("[e2e/app] building the example application...");
buildApp();
console.log("[e2e/app] ensuring the batch wallet...");
await ensureWallet();
// This run's own physical user, in a directory of its own. Nothing is shared with any other
// 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 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
// nothing. Sequential contexts over the one profile dir; never two at once.
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;
try {
walletSize = await exportWalletNgw(exportCtx, sharedWalletFile);
walletSize = await exportWalletFile(exportCtx, sharedWalletFile, WALLET.password);
} finally {
await closeContext("wallet-export", exportCtx);
}
ctx = await launchWalletContext("actors");
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD);
ctx = await launchWatchedContext("actors", wallet.dir);
actorsBrowser = ctx;
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET.password);
closeServer = served.close;
const url = served.url;
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`);
@@ -1009,7 +854,7 @@ async function main(): Promise<void> {
run: async () => {
const newcomer = `newcomer-${t}`;
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
// the body reads without an assertion at every use.
let page: Page | null = null;
@@ -1070,7 +915,7 @@ async function main(): Promise<void> {
gate.locator('a[target="_blank"]').click(),
]);
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", () =>
within("the wallet application's tab to close", WALLET_TAB_CLOSE_MS, () => walletPage.close()),
@@ -1089,7 +934,7 @@ async function main(): Promise<void> {
).catch(() => {});
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, () =>
completeBrokerLogin(visitor, url),
completeBrokerLogin(visitor, url, WALLET.password),
);
check(
"the application comes back inside the broker iframe",
@@ -1127,7 +972,7 @@ async function main(): Promise<void> {
} finally {
if (page) await closeQuietly("the newcomer's page", () => page!.close());
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 () => {
const returning = `returning-${t}`;
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 again: Page | null = null;
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");
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");
await closeQuietly("the wallet application's tab", () =>
@@ -1223,7 +1068,7 @@ async function main(): Promise<void> {
).catch(() => {});
at("first visit: handed over to the broker");
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);
// 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(),
);
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");
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 (again) await closeQuietly("the return visit's page", () => again!.close());
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);
closeServer?.();
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);
}
main().catch((e) => {
// Anything the journeys did not catch — a refused run lock, a wallet export that hung, a
// browser lost during setup. Reported through the SAME summary as everything else rather
// Anything the journeys did not catch — a wallet that could not be minted, an export that
// 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
// 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.
@@ -23,20 +23,15 @@
* bun run e2e/reactivity-doc-subscribe.ts
* (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
* the SAME persistent wallet context — a second concurrent verifier session on one
* shared wallet (as faithfulReconnect does) — and writes from it.
*/
import type { Frame, Page, BrowserContext } from "playwright";
import {
buildBundle,
serveHarness,
ensureWallet,
launchWalletContext,
setupBrokerPage,
} from "./broker";
import { launchWatchedContext, setupBrokerPage, type RunProfile } from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
@@ -92,7 +87,7 @@ async function openSession(
if (m.type() === "error") console.error(`[iframe console:${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.status() === "connected", {
timeout: 60000,
@@ -110,14 +105,14 @@ const STATE_TIMEOUT_MS = 20000;
async function main(): Promise<void> {
console.log("[reactivity] building SDK page bundle...");
buildBundle();
console.log("[reactivity] ensuring dedicated lib wallet...");
await ensureWallet();
console.log("[reactivity] minting this run's wallet...");
const wallet: RunProfile = await mintBatchWallet("the reactivity suite (e2e/reactivity-doc-subscribe.ts)");
const { url, close: closeServer } = await serveHarness();
console.log(`[reactivity] harness served at ${url}`);
let ctx: BrowserContext | null = null;
try {
ctx = await launchWalletContext();
ctx = await launchWatchedContext("reactivity", wallet.dir);
// ── Session A (the subscriber for both cases) ────────────────────────────
const A = await openSession(ctx, url, "A");
@@ -263,6 +258,7 @@ async function main(): Promise<void> {
} finally {
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
closeServer();
wallet.discard();
}
// ── 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`.
*/
import * as fs from "node:fs";
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 };
const results: Check[] = [];
@@ -47,13 +47,20 @@ async function main(): Promise<void> {
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
let ctx: BrowserContext | null = null;
let dir: string | null = null;
let profile: RunProfile | null = null;
let page: Page | null = null;
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;
dir = fresh.dir;
console.log(`[repro] fresh wallet: ${fresh.name}`);
profile = fresh.profile;
console.log(`[repro] fresh wallet: ${credentials.name}`);
page = await ctx.newPage();
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)...");
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.status() === "connected", {
timeout: 60000,
@@ -102,7 +109,7 @@ async function main(): Promise<void> {
} finally {
try { if (page) await page.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();
}
-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 type { Frame, Page, BrowserContext } from "playwright";
import {
buildBundle,
serveHarness,
armSuiteDeadline,
closeContext,
ensureWallet,
launchWalletContext,
launchCleanProfileContext,
importWalletViaFile,
closeQuietly,
emptyProfileContext,
importWalletFile,
launchWatchedContext,
newPage,
setupBrokerPage,
PROFILE_DIR,
} from "./broker";
import { armSuiteDeadline, closeQuietly, within } from "./deadline";
import { acquireRunLock } from "./run-lock";
within,
type RunProfile,
} from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
@@ -105,7 +104,7 @@ async function faithfulReconnect(
p.on("console", (m) => {
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.status() === "connected", { timeout: 60000 });
return { page: p, frame };
@@ -161,21 +160,21 @@ function assertWithinBudget(): 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);
console.log("[e2e] building SDK page bundle...");
buildBundle();
console.log("[e2e] ensuring dedicated lib wallet...");
await ensureWallet();
// This batch's own physical user, in a profile directory of its own. Nothing to serialise
// 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();
console.log(`[e2e] harness served at ${url}`);
let ctx: BrowserContext | null = null;
let page: Page | null = null;
try {
ctx = await launchWalletContext("sdk-harness");
ctx = await launchWatchedContext("sdk-harness", wallet.dir);
page = await newPage("the SDK harness", ctx);
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => {
@@ -183,7 +182,7 @@ async function main(): Promise<void> {
});
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.
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"));
let cleanCtx: BrowserContext | null = null;
let cleanDir: string | null = null;
let cleanProfile: RunProfile | null = null;
let cleanPage: Page | null = null;
try {
const launched = await launchCleanProfileContext();
const launched = await emptyProfileContext("the clean-profile cold read");
cleanCtx = launched.ctx;
cleanDir = launched.dir;
cleanProfile = launched.profile;
cleanPage = await newPage("the clean-profile session", cleanCtx);
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()); });
// Import the SAME wallet into the empty profile (broker-only repos), then open
// the SDK page in a fresh broker session over it.
await importWalletViaFile(cleanPage, ngwPath);
const cleanFrame = await setupBrokerPage(cleanPage, url);
await importWalletFile(cleanPage, ngwPath, WALLET.password);
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.status() === "connected", { timeout: 60000 });
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
@@ -528,7 +527,7 @@ async function main(): Promise<void> {
} finally {
if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close());
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 */ }
}
});
@@ -826,6 +825,10 @@ async function main(): Promise<void> {
if (page) await closeQuietly("the SDK harness page", () => page!.close());
if (ctx) await closeContext("sdk-harness", ctx);
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 ───────────────────────────────────────────────────────────────