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
+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.