Files
ng-eventually/packages/polyfill/e2e/deadline.ts
T
Sylvain Duchesne ed0f872f5d test(e2e): un échec ne peut plus emporter les suivants, et les bornes sont mesurées
Trois choses, dont une qui explique pourquoi aucun diagnostic n'aboutissait.

SIGN_IN_MS valait 180 s alors que la somme de ses propres étapes en faisait 270.
La borne englobante se déclenchait donc TOUJOURS avant celle de l'étape en
cause, et ne pouvait dire qu'une chose : « bob-… to sign in ». Le message était
structurellement condamné à ne rien apprendre — on a cherché des jours une cause
que le harnais s'interdisait de nommer. Les bornes englobantes sont maintenant
des sommes calculées de leurs étapes.

Les parcours sont isolés. La suite déclare ses 7 parcours et leurs 27
vérifications AVANT tout lancement de navigateur, et rend donc toujours 34
lignes — y compris quand le montage meurt, où les parcours non exécutés sont
rapportés comme tels. Auparavant le total valait 24, 26 ou 27 selon ce qui
mourait : deux exécutions ne mesuraient même pas la même chose. Un échec est
contenu, pas absorbé — il reste compté.

Et chaque borne est dimensionnée sur une durée MESURÉE, inscrite à côté d'elle
dans le code. Le premier rendu d'un acteur prend 4,9 à 7,4 s et vaut 45 s ; la
traversée du broker 1,3 à 2,8 s et vaut 75 s. Un nombre nu n'apprend rien et
pourrit en silence.

Au passage : un walletPage.close() n'avait aucune borne du tout.
2026-08-16 12:28:44 +02:00

219 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;