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,113 @@
|
||||
/**
|
||||
* Every browser this harness opens, launched and watched the same way.
|
||||
*/
|
||||
|
||||
import { chromium, type BrowserContext, type Page } from "playwright";
|
||||
import {
|
||||
CONTEXT_ACTION_MS,
|
||||
CONTEXT_NAVIGATION_MS,
|
||||
browserLost,
|
||||
closeQuietly,
|
||||
within,
|
||||
} from "./deadline";
|
||||
|
||||
/** Launching a browser is local — 30 s is Playwright's own default, doubled. */
|
||||
export const LAUNCH_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Opening a page in a live browser is instant — measured 0.0–0.1 s over a run. Bounded at
|
||||
* 10 s, which is a hundred times the measurement and still fails while a reader is watching.
|
||||
* Exported because a caller that wraps `newPage` in a TIGHTER bound of its own would fire
|
||||
* first and report its own name instead of this one.
|
||||
*/
|
||||
export const NEW_PAGE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* What this harness needs Chromium to allow: the application under test is served from
|
||||
* `127.0.0.1` and loaded inside a broker iframe on a public origin, which is a private-network
|
||||
* request and a cross-origin one at once.
|
||||
*/
|
||||
const LAUNCH_ARGS = [
|
||||
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
||||
"--allow-insecure-localhost",
|
||||
"--disable-web-security",
|
||||
];
|
||||
|
||||
/**
|
||||
* A real Chromium rather than the headless shell: the shell has no support for the extensions
|
||||
* of a full browser, and the wallet application's flows have been observed only on the full
|
||||
* build. Falls back to Playwright's own choice when no full build is installed beside it.
|
||||
*/
|
||||
function resolveChromePath(): string | undefined {
|
||||
const p = chromium
|
||||
.executablePath()
|
||||
.replace("chrome-headless-shell", "chrome")
|
||||
.replace("chromium_headless_shell", "chromium");
|
||||
return p.includes("headless") ? undefined : p;
|
||||
}
|
||||
|
||||
/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */
|
||||
const closingOnPurpose = new WeakSet<BrowserContext>();
|
||||
|
||||
/**
|
||||
* Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied and
|
||||
* its disappearance turned into an immediate, named failure.
|
||||
*
|
||||
* The watch is the load-bearing part. VERIFIED 2026-08-11: a browser can exit mid-run — the
|
||||
* devtools pipe between the runner and Chromium is terminated and Chromium shuts down
|
||||
* (exitCode=0) — and Playwright does NOT reject the calls already waiting on it. A bounded
|
||||
* wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, and
|
||||
* `context.close()` in a `finally`) waits for ever. That is how a 60-second failure became
|
||||
* three runs killed at 50 and 68 minutes having printed nothing.
|
||||
*
|
||||
* So the context's own `close` event is listened to, and anything it was not asked to do is
|
||||
* declared a loss once, loudly, for every wait at once.
|
||||
*/
|
||||
export async function launchWatchedContext(label: string, dir: string): Promise<BrowserContext> {
|
||||
const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () =>
|
||||
chromium.launchPersistentContext(dir, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
timeout: LAUNCH_MS,
|
||||
}),
|
||||
);
|
||||
ctx.setDefaultTimeout(CONTEXT_ACTION_MS);
|
||||
ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS);
|
||||
const gone = (how: string): void => {
|
||||
if (closingOnPurpose.has(ctx)) return;
|
||||
browserLost(
|
||||
`the ${label} browser went away mid-run — ${how}. Every wait on it is now ` +
|
||||
"unanswerable, so the run stops here instead of waiting on a browser that " +
|
||||
"no longer exists",
|
||||
);
|
||||
};
|
||||
// Both signals, and NEITHER of them covers the loss that hurts most — which is the whole
|
||||
// reason the deadlines are not optional.
|
||||
//
|
||||
// VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the
|
||||
// failure this harness actually suffers — Chromium logging "Connection terminated while
|
||||
// reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its
|
||||
// client never learns the pipe is gone, so every call already in flight simply waits, and
|
||||
// every call after it waits too. That is why a browser dying used to cost an hour of
|
||||
// silence, and why no event-based guard can be the protection here: only a deadline can
|
||||
// (and `known-failures.ts` is what turns the deadline back into the right name).
|
||||
//
|
||||
// They are wired anyway because they DO catch the losses they can see (a context closed by
|
||||
// something nobody asked), and those are free to catch immediately rather than at the end
|
||||
// of a bound.
|
||||
ctx.on("close", () => gone("its context closed and nobody asked it to"));
|
||||
ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped"));
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Close a context we own, bounded, without its `close` event being read as a loss. */
|
||||
export async function closeContext(label: string, ctx: BrowserContext): Promise<void> {
|
||||
closingOnPurpose.add(ctx);
|
||||
await closeQuietly(`the ${label} context`, () => ctx.close());
|
||||
}
|
||||
|
||||
/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */
|
||||
export function newPage(label: string, ctx: BrowserContext): Promise<Page> {
|
||||
return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage());
|
||||
}
|
||||
Reference in New Issue
Block a user