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:
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Deadlines — so a wait that cannot end FAILS, named, instead of hanging.
|
||||
*
|
||||
* ── Why this module exists ───────────────────────────────────────────────────
|
||||
* A harness that hangs is worse than one that fails. A failure names a suspect and costs a
|
||||
* minute; a hang costs an hour and leaves every measurement of the session undecidable —
|
||||
* was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites
|
||||
* lean on have NO bound at all: `frame.evaluate()` (which is what every bridge call into a
|
||||
* page is) and `context.newPage()`. Playwright applies no timeout to either.
|
||||
*
|
||||
* And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away
|
||||
* mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies
|
||||
* INSIDE its own cleanup, after its last journey, without ever printing its summary or its
|
||||
* failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness
|
||||
* was killed for, three times.
|
||||
*
|
||||
* So: every wait that can block gets a deadline, and on expiry an error that says WHAT it
|
||||
* was waiting for and WHERE — the chain of journeys and steps it sits inside (see
|
||||
* {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing
|
||||
* from "which wait" to "which of these thirty-two".
|
||||
*
|
||||
* ── Bounds are generous on purpose ───────────────────────────────────────────
|
||||
* The numbers are sized from OBSERVED healthy timings with a wide margin (see each
|
||||
* caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a
|
||||
* bound that fires on a slow broker manufactures exactly the false diagnosis it exists to
|
||||
* prevent.
|
||||
*/
|
||||
|
||||
/** Thrown when a bounded wait outlives its deadline. */
|
||||
export class DeadlineExceeded extends Error {
|
||||
constructor(what: string, ms: number, where: string) {
|
||||
super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`);
|
||||
this.name = "DeadlineExceeded";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown at every wait in flight when the browser they all depend on has gone away.
|
||||
*
|
||||
* Without it, a dead browser is discovered one 60-second timeout at a time — or never, on
|
||||
* the waits Playwright does not bound. The suite has nothing left to measure at that
|
||||
* point, so the useful thing is to say so once, immediately, and name the loss.
|
||||
*/
|
||||
export class BrowserGone extends Error {
|
||||
constructor(reason: string, what: string, where: string) {
|
||||
super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`);
|
||||
this.name = "BrowserGone";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
what: string;
|
||||
where: string;
|
||||
ms: number;
|
||||
startedAt: number;
|
||||
abandon: (e: Error) => void;
|
||||
}
|
||||
|
||||
/** Everything currently being waited on, so a loss can name every casualty at once. */
|
||||
const pending = new Set<Pending>();
|
||||
|
||||
/** Set once the run has lost the thing every wait depends on. */
|
||||
let lost: string | null = null;
|
||||
|
||||
/**
|
||||
* Where a wait sits, as the chain of waits enclosing it — `journey X › alice to sign in`.
|
||||
*
|
||||
* Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames
|
||||
* across `await` boundaries: measured 2026-08-11, a `within` called from an async function
|
||||
* reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site
|
||||
* is silently wrong exactly when it is needed. The enclosing chain is better anyway — a
|
||||
* reader wants "which journey, which step" far more than a line number, and journeys and
|
||||
* steps are themselves bounded waits, so the chain is already there to be read.
|
||||
*
|
||||
* These suites are strictly sequential, which is what makes "everything else in flight" the
|
||||
* same thing as "everything enclosing this". A concurrent harness would need real context
|
||||
* propagation.
|
||||
*/
|
||||
function enclosing(): string {
|
||||
const chain = [...pending].map((p) => p.what);
|
||||
return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" › ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared —
|
||||
* reject with an error naming what was being waited for and where.
|
||||
*
|
||||
* The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its
|
||||
* eventual rejection is absorbed instead, because a race loser surfacing as an unhandled
|
||||
* rejection would crash the process minutes after the real failure was already reported.
|
||||
*/
|
||||
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
|
||||
if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing()));
|
||||
return bounded(what, ms, enclosing(), task);
|
||||
}
|
||||
|
||||
/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */
|
||||
function bounded<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let entry!: Pending;
|
||||
const interrupted = new Promise<never>((_, reject) => {
|
||||
entry = { what, where, ms, startedAt: Date.now(), abandon: reject };
|
||||
timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms);
|
||||
});
|
||||
pending.add(entry);
|
||||
const running = task();
|
||||
running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection
|
||||
return Promise.race([running, interrupted]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
pending.delete(entry);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the browser every wait depends on has gone, and abandon them all now.
|
||||
*
|
||||
* Idempotent, and one-way: once a run has lost its browser there is nothing further to
|
||||
* measure, so later waits are refused rather than left to time out one by one.
|
||||
*/
|
||||
export function browserLost(reason: string): void {
|
||||
if (lost !== null) return;
|
||||
lost = reason;
|
||||
console.error(`\n[e2e] ${reason}`);
|
||||
if (pending.size > 0) {
|
||||
console.error(` ${pending.size} wait(s) were in flight and are abandoned:`);
|
||||
for (const p of pending) console.error(` - ${p.what} [${p.where}]`);
|
||||
}
|
||||
for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where));
|
||||
}
|
||||
|
||||
/**
|
||||
* The loss already declared, or `null` while the run still has a browser.
|
||||
*
|
||||
* Read by the failure-mode recognition (`known-failures.ts`) so that a diagnosis asked for
|
||||
* AFTER a loss answers instantly with the loss, instead of spending a probe's bound
|
||||
* re-discovering what is already known.
|
||||
*/
|
||||
export function lossDeclared(): string | null {
|
||||
return lost;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ENCLOSING bound, computed from the bounds it encloses rather than picked.
|
||||
*
|
||||
* ── Why this is an addition and not a comment ────────────────────────────────
|
||||
* An enclosing deadline shorter than its own steps can only ever fire FIRST, so every
|
||||
* failure underneath it is reported as "the enclosure timed out" and the step that actually
|
||||
* hung is never named. Observed at length: a sign-in bounded at 3 min sat over steps
|
||||
* totalling 4.5 min, and for days every sign-in failure said the same four words while the
|
||||
* real step stayed anonymous. Days went into looking for a cause the harness was
|
||||
* structurally incapable of reporting.
|
||||
*
|
||||
* A comment saying "keep this above the sum" is a discipline; an addition is a mechanism,
|
||||
* and the mechanism is what survives the next edit — shrink a step's bound and the
|
||||
* enclosure shrinks with it. That is the lever: the steps, never the enclosure.
|
||||
*
|
||||
* `margin` is for the enclosure's OWN overhead (the code between the steps), not for
|
||||
* comfort — an enclosure sized "generously" above its steps just delays every report.
|
||||
*/
|
||||
export function enclosingBound(steps: readonly number[], margin: number): number {
|
||||
return steps.reduce((sum, ms) => sum + ms, 0) + margin;
|
||||
}
|
||||
|
||||
/** Teardown bound: a close that has not returned in 30s is not going to. */
|
||||
export const CLOSE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Close a page/context/server under a deadline, reporting rather than throwing.
|
||||
*
|
||||
* Teardown is where a bound matters most and an exception matters least: the verdict is
|
||||
* already decided, so a close that never returns must not be what the run dies of. This is
|
||||
* the exact shape of the observed hang — `BrowserContext.close()` on a browser that had
|
||||
* already exited, inside a `finally`, swallowing the summary that was on its way out.
|
||||
*
|
||||
* Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when
|
||||
* closing matters most. Skipping it there would leave the Chromium processes of a failed
|
||||
* run alive, and the next run would inherit them.
|
||||
*/
|
||||
export async function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => {
|
||||
await close();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the suite's own wall clock. On expiry, name every wait still in flight and exit.
|
||||
*
|
||||
* The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It
|
||||
* reports before it dies, because "the run was killed" is the uninformative message that
|
||||
* cost the hours this module exists to stop spending.
|
||||
*
|
||||
* `unref`ed, so a healthy run is never held open by its own watchdog.
|
||||
*
|
||||
* `thenReport` lets a suite print its own summary before the process goes — without it a run
|
||||
* that trips this watchdog reports its waits and then vanishes, so its check count is zero
|
||||
* and comparable with nothing. (A suite built on `report.ts` passes its `finish`.)
|
||||
*/
|
||||
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
|
||||
const startedAt = Date.now();
|
||||
const timer = setTimeout(() => {
|
||||
console.error(
|
||||
`\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` +
|
||||
" This is a HANG, not a verdict.",
|
||||
);
|
||||
if (pending.size === 0) {
|
||||
console.error(
|
||||
" Nothing was inside a bounded wait, so the block is in unbounded code: " +
|
||||
"wrap the step it stopped at with `within(...)`.",
|
||||
);
|
||||
} else {
|
||||
console.error(` Waits still in flight (${pending.size}):`);
|
||||
for (const p of pending) {
|
||||
console.error(
|
||||
` - ${p.what} — ${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`);
|
||||
// A suite that can still say what it did and did not verify must be allowed to say it —
|
||||
// otherwise the watchdog, whose whole purpose is to replace a silent kill with a report,
|
||||
// produces its own silent kill. `thenReport` is expected to exit; the line below is the
|
||||
// fallback for a caller that has nothing to report.
|
||||
if (thenReport !== undefined) thenReport();
|
||||
process.exit(1);
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright's per-context defaults, set explicitly so the bound on every locator action and
|
||||
* navigation is a decision in this file rather than a library default nobody looked up.
|
||||
*
|
||||
* The value is Playwright's own 30s, deliberately: raising it to 120s was tried on
|
||||
* 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also
|
||||
* how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can
|
||||
* re-render under a click ("element was detached from the DOM, retrying"), and at 120s that
|
||||
* flake took two minutes to surface instead of thirty seconds. Every action and navigation
|
||||
* here already had a bound; the waits that had NONE are the ones this module wraps
|
||||
* (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout.
|
||||
*/
|
||||
export const CONTEXT_ACTION_MS = 30_000;
|
||||
export const CONTEXT_NAVIGATION_MS = 30_000;
|
||||
|
||||
/** The first line of whatever was thrown — the form a report carries. */
|
||||
export function firstLine(e: unknown): string {
|
||||
return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)";
|
||||
}
|
||||
Reference in New Issue
Block a user