test(e2e): le parcours d'un primo-arrivant, et un harnais qui échoue au lieu de se pendre
Aucun parcours n'avait jamais marché le chemin d'un nouvel arrivant : tous pré-injectaient l'identifiant dans l'URL, ce qui fait résoudre l'identité sans jamais afficher la barrière. La suite était verte par-dessus un lien de téléchargement pointant sur un fichier que personne ne servait — et le serveur de test répondait la page HTML de l'application pour tout chemin inconnu, donc un fichier manquant ne POUVAIT pas échouer. Le nouveau parcours part d'un profil vide : barrière, téléchargement réel, import dans l'application portefeuille, saisie de l'identifiant, remise au broker, retour dans l'iframe. Il vérifie neuf points, dont celui qui compte — l'identifiant a survécu et l'identité rapportée est celle qui a été saisie. Le harnais, lui, se pendait au lieu d'échouer. Cause observée : le tuyau devtools de Chromium lâche et Playwright n'émet ni close ni disconnected, si bien que la suite bloquait dans son propre nettoyage sans imprimer ni résumé ni l'échec déjà en route. Toutes les attentes sont désormais bornées et nomment ce qu'elles attendaient ; vérifié en cassant délibérément une attente, et observé en conditions réelles — trois minutes et « gave up waiting for: alice to sign in » là où j'ai tué trois exécutions d'une heure ce matin. Deux exécutions simultanées ne se détruisent plus : verrou atomique sur le profil, et récupération d'un navigateur laissé par une exécution tuée. Le marqueur devient .user-consumed — il n'a jamais attesté d'une disponibilité, seulement qu'un lot avait déjà pris l'utilisateur de ce profil. Au passage, la destruction du profil dépendait du marqueur, écrit en FIN de lot : une exécution tuée avant laissait un profil que la suivante réutilisait, et héritait de sa casse. Elle dépend maintenant du profil. La suite applicative reste non mesurée sur cette machine : un conteneur en boucle de redémarrage recycle son interface réseau, et sept exécutions sur dix échouent sur le transport. Trois sont passées 21/21.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function armSuiteDeadline(suite: string, ms: number): 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)}`);
|
||||
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;
|
||||
Reference in New Issue
Block a user