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
@@ -0,0 +1,106 @@
/**
* The failure modes this harness cannot fix, and must therefore NAME.
*
* ── Why naming is the whole of the job ───────────────────────────────────────
* Both modes below present as a bounded wait expiring on whatever operation happened to be in
* flight — a `fill`, a `selectOption`, a `click`. Reported that way they read as product
* defects, and they have been diagnosed as such more than once: a run whose browser had
* stopped answering reported three timeouts on three different innocent selectors, none of
* them naming the browser. A whole day went into one of those.
*
* So when a wait fails, the honest question is asked before the verdict is written: does the
* browser still answer AT ALL? A trivial round-trip settles it in milliseconds when things are
* healthy, so asking costs a healthy run nothing.
*
* ── The two modes ────────────────────────────────────────────────────────────
* **A dropped devtools pipe.** Chromium's control pipe drops mid-run: it logs a terminated-pipe
* message and exits cleanly, and Playwright emits NEITHER `close` NOR `disconnected` — observed
* four times out of four. From the client's side the browser simply stops answering, so every
* wait on it burns its bound and the unbounded ones wait for ever. It is not caused by how the
* child process is spawned, nor by a leftover holding the profile, nor by overlapping launches
* — all three were probed and ruled out. It looks like Playwright losing its file descriptors
* without telling its client.
*
* **A context that stops answering.** The same shape at frame level: a frame that is attached,
* on the right URL, and holds NOTHING — what a RELOADED iframe looks like from the outside.
* VERIFIED 2026-08-16, one actor's frame reached it mid-run and the next three journeys each
* reported a 30 s timeout on a different innocent selector.
*
* ── What a named deadline does NOT prove ─────────────────────────────────────
* That the transport is at fault. A deadline says only that something did not happen in time;
* reaching for the environment is the comfortable answer because it absolves the code. The
* worst instance of that reflex here was a one-line harness bug — an application frame matched
* by SUBSTRING — blamed on the broker and on the host network for a day. Read your own harness
* first, and call it transport only once you can name the mechanism.
*/
import type { BrowserContext, Frame, Page } from "playwright";
import { DeadlineExceeded, firstLine, lossDeclared, within } from "./deadline";
/** Asking a live browser something trivial: it answers in milliseconds, or it is gone. */
export const BROWSER_PROBE_MS = 5_000;
/**
* Asking a live frame whether it still holds the application. A `count()` is one round-trip
* and does not wait for the element, so it answers in milliseconds or the frame is gone — the
* probe cannot itself become the hang it exists to name.
*/
export const FRAME_PROBE_MS = 10_000;
/**
* Whether the browser has stopped answering — as a sentence naming the mode, or `null` when it
* answers normally and the operation that failed is the real suspect.
*
* Consult this on a failure path, never in the success path: it exists to REPLACE a misleading
* verdict, not to add a check.
*/
export async function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null> {
// Already established, by the `close`/`disconnected` listeners that can see their losses.
// Answering from it costs nothing and says the same thing.
const declared = lossDeclared();
if (declared !== null) return declared;
const live = ctx.pages().filter((p) => !p.isClosed());
if (live.length === 0) return null; // nothing to ask — no verdict, rather than a wrong one
const page = live[0]!;
try {
await within(`the ${label} browser to answer a trivial question`, BROWSER_PROBE_MS, () =>
page.evaluate(() => 1),
);
return null;
} catch (e) {
if (e instanceof DeadlineExceeded) {
return (
`the ${label} browser STOPPED ANSWERING — a trivial round-trip did not come back in ` +
`${BROWSER_PROBE_MS / 1000}s. This is the dropped devtools pipe (Chromium exits and ` +
"Playwright emits neither `close` nor `disconnected`), so whatever operation was in " +
"flight is a casualty and not the cause. It is not ours to fix — re-run, and do not " +
"read this as a verdict on the code under test"
);
}
return `the ${label} browser refused a trivial question: ${firstLine(e)}`;
}
}
/**
* Why `frame` cannot be driven, or `null` when it can.
*
* `marker` is the selector that proves the application is still in the frame — the caller's,
* because only the application knows what its own presence looks like. The THIRD state is the
* one that actually happens and the one no naive check catches: attached, on the right URL,
* and empty.
*/
export async function frameTrouble(
id: string,
page: Page,
frame: Frame,
marker: string,
): Promise<string | null> {
if (page.isClosed()) return `${id}'s page has been closed`;
if (frame.isDetached()) return `${id}'s application frame is detached`;
const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () =>
frame.locator(marker).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;
}