ed0f872f5d
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.
219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
/**
|
||
* 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;
|