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
+140
View File
@@ -0,0 +1,140 @@
/**
* Browser profiles — one per run, never shared, never inherited.
*
* ── Why a run owns its profile instead of borrowing a shared one ─────────────
* A run mints its own physical NextGraph user and must not inherit the previous run's. That
* discipline is not an optimisation: a wallet reused across runs ACCUMULATES — every run
* leaves behind the identities and documents it created, nothing removes them, and a cold
* resynchronisation is O(the user's size). A wallet kept for a month took 286 s on a single
* sync step against 250 s a week earlier, and the drift was invisible because it was never
* measured against a stable baseline. A fresh user per run makes that duration comparable
* from one run to the next instead of a number that only ever grows.
*
* The obvious way to get a fresh user is to WIPE a profile at a fixed path — which is what
* this harness used to do, and it is why it needed a lock. A wipe destroys a profile that
* another run may be using, so runs had to be serialised, and a suite belonging to a
* consuming application — run from its own checkout, against the same broker — collided with
* ours exactly as two of ours would, invisibly to both. The lock could never have fixed that:
* it guarded one repository's idea of a path.
*
* A directory of its own removes the problem rather than exporting it. There is nothing to
* serialise, concurrent runs are independent by construction, and "one physical user per run,
* never reused" stops being a rule anyone can forget — a directory that did not exist a
* moment ago cannot hold a previous run's user.
*
* What the profile still IS, and must remain: persistent for the WHOLE run. A run opens
* several browser contexts over it in sequence (a reconnection is exactly that), and the
* contracts about reconnecting faithfully and not forking an account are checks on that
* persistence.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/** A profile directory this run owns and will remove. */
export interface RunProfile {
/** The directory to launch a persistent context on. */
readonly dir: string;
/** What it is for, as it appears in logs. */
readonly purpose: string;
/**
* Kill whatever still holds it, then remove it. Idempotent, and also run automatically when
* the process leaves (see below), so a killed run cleans up after itself.
*/
discard(): void;
}
const live = new Set<RunProfile>();
let leavingHandlersInstalled = false;
/** Is that process still alive? Signal 0 tests for existence without touching it. */
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
// EPERM means it exists and is someone else's — still alive, still holding it.
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* The Chromium still holding `dir`, if any. Chromium names the holder itself: `SingletonLock`
* is a symlink to `<host>-<pid>`.
*/
function holderOf(dir: string): number | null {
let target: string;
try {
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
} catch {
return null; // no lock, nothing holding it
}
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
return Number.isInteger(pid) && pid > 0 && isAlive(pid) ? pid : null;
}
/**
* A profile directory of this run's own, under the system temp dir.
*
* Under the TEMP dir and not the repository, deliberately: a profile holds a wallet, a wallet
* is an identity, and an identity must never end up committed. It also means two checkouts of
* the same suite cannot land on the same path.
*/
export function newRunProfile(purpose: string): RunProfile {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-profile-"));
let discarded = false;
const profile: RunProfile = {
dir,
purpose,
discard: () => {
if (discarded) return;
discarded = true;
live.delete(profile);
// A run that fails or is killed leaves its Chromium ALIVE: `BrowserContext.close()` in
// the teardown gives up after its bound, and a `kill -9` on the runner never gets there
// at all. Nothing else will ever want this directory, so the orphan cannot poison a
// later run the way it used to — but it would sit on the host's memory for ever, and a
// loaded host is how this suite manufactures its own flakiness. Each run therefore
// clears its OWN leftovers, which is the one moment where it is certainly safe.
const holder = holderOf(dir);
if (holder !== null) {
try {
process.kill(holder, "SIGKILL");
} catch {
/* gone between the check and the signal */
}
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* a temp dir the OS will collect anyway */
}
},
};
live.add(profile);
installLeavingHandlers();
return profile;
}
/**
* Discard every profile still live when the process leaves — including the ways out nobody
* plans for.
*
* `exit` covers the normal end and `process.exit()`, which is how these suites finish; the
* signal handlers cover Ctrl-C and `kill`, which is how a hung run ends. Everything here is
* synchronous, because an `exit` handler is the only thing that runs at that point.
*/
function installLeavingHandlers(): void {
if (leavingHandlersInstalled) return;
leavingHandlersInstalled = true;
process.on("exit", () => {
for (const profile of [...live]) profile.discard();
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
for (const profile of [...live]) profile.discard();
process.exit(130);
});
}
}