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,258 @@
|
||||
/**
|
||||
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
|
||||
* bytes out as a `.ngw` file, and put a `.ngw` file into a browser profile.
|
||||
*
|
||||
* ── Why the real interface and not a shortcut ────────────────────────────────
|
||||
* A wallet obtained any other way is not the one a person has. The wallet application is an
|
||||
* application like any other, so this drives it: click for click, field for field. That is
|
||||
* also what makes the harness notice when the flow upstream changes, instead of testing
|
||||
* against a fixture that quietly stopped resembling it.
|
||||
*
|
||||
* The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`.
|
||||
*
|
||||
* ── On the fixed waits in these flows ────────────────────────────────────────
|
||||
* The creation and import flows below contain a handful of `waitForTimeout` calls, each on a
|
||||
* step where the wallet application offers NO observable signal that the work is finished
|
||||
* (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing).
|
||||
* They are inherited as-is, with their measured durations, and they are the only fixed waits
|
||||
* in this package — everything else waits for a condition. They are the first thing to replace
|
||||
* if the wallet application ever grows a marker to wait on.
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { closeQuietly, within } from "./deadline";
|
||||
import { launchWatchedContext, newPage } from "./browser";
|
||||
import { newRunProfile, type RunProfile } from "./profiles";
|
||||
import { serveOnEphemeralPort } from "./serve";
|
||||
import { setupBrokerPage } from "./broker";
|
||||
import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui";
|
||||
import type { ExportedWallet } from "./wallet-export-page";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
|
||||
const BUILD_MS = 60_000;
|
||||
/**
|
||||
* The whole wallet export measures ~7 s against a real broker. Bounded at 60 s ≈ 8x.
|
||||
*
|
||||
* It was two minutes once, and that cost a run twice over: the export hung, and the suite
|
||||
* spent two full minutes reaching a verdict it could have reached in one — before dying
|
||||
* without a summary, because an export runs in the SETUP, ahead of every journey.
|
||||
*/
|
||||
const EXPORT_MS = 60_000;
|
||||
/** The export page appearing, then its session connecting. Both against a live broker. */
|
||||
const EXPORT_PAGE_MS = 30_000;
|
||||
const EXPORT_CONNECT_MS = 60_000;
|
||||
|
||||
/** A wallet's name and the password that opens it. */
|
||||
export interface WalletCredentials {
|
||||
readonly name: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
|
||||
*
|
||||
* The first unlock is not decoration: it is what bootstraps the verifier's repos from the
|
||||
* broker, and a wallet that has never been unlocked is not usable by an application.
|
||||
*/
|
||||
export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise<void> {
|
||||
const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx));
|
||||
page.on("pageerror", () => {});
|
||||
|
||||
await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true });
|
||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await createButton.click();
|
||||
|
||||
await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {});
|
||||
const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true });
|
||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await acceptButton.click();
|
||||
|
||||
const usernameInput = page.locator(WALLET_CREATION.username);
|
||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||
await usernameInput.fill(credentials.name);
|
||||
const passwordInput = page.locator(WALLET_CREATION.password);
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await passwordInput.fill(credentials.password);
|
||||
|
||||
const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false });
|
||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// First login → bootstrap the verifier repos from the broker. This is what a brand-new
|
||||
// wallet does on its very first unlock.
|
||||
const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet);
|
||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const loginPassword = page.locator(WALLET_CREATION.passwordField);
|
||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||
await loginPassword.fill(credentials.password);
|
||||
await loginPassword.press("Enter");
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* This run's physical user: a profile of its own, and a wallet minted into it. The creation
|
||||
* context is closed — the caller opens its own contexts over `profile.dir`, one at a time.
|
||||
*
|
||||
* One per run, never inherited from a previous one: see `profiles.ts` for why that is a
|
||||
* property of the directory rather than a rule anyone has to remember.
|
||||
*/
|
||||
export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise<RunProfile> {
|
||||
const profile = newRunProfile(purpose);
|
||||
const ctx = await launchWatchedContext("wallet-creation", profile.dir);
|
||||
try {
|
||||
await createWalletInContext(ctx, credentials);
|
||||
} finally {
|
||||
const { closeContext } = await import("./browser");
|
||||
await closeContext("wallet-creation", ctx);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, with the context left OPEN.
|
||||
*
|
||||
* For the cold-start case: the caller then opens its application in the SAME profile, i.e. the
|
||||
* very first application session over a wallet that has never run one. Closing and relaunching
|
||||
* would not be the same thing.
|
||||
*/
|
||||
export async function mintWalletProfileKeepingContext(
|
||||
purpose: string,
|
||||
credentials: WalletCredentials,
|
||||
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
|
||||
const profile = newRunProfile(purpose);
|
||||
const ctx = await launchWatchedContext("fresh-wallet", profile.dir);
|
||||
await createWalletInContext(ctx, credentials);
|
||||
const first = ctx.pages()[0];
|
||||
if (first !== undefined) await first.close().catch(() => {});
|
||||
return { ctx, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* A context on an EMPTY profile: no wallet, no local repo cache.
|
||||
*
|
||||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: a wallet's
|
||||
* repos are on the broker but NOT in this profile, so a session over it starts with nothing
|
||||
* local. The caller imports a wallet (see {@link importWalletFile}) before opening the
|
||||
* application.
|
||||
*/
|
||||
export async function emptyProfileContext(
|
||||
purpose: string,
|
||||
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
|
||||
const profile = newRunProfile(purpose);
|
||||
const ctx = await launchWatchedContext("clean-profile", profile.dir);
|
||||
return { ctx, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it.
|
||||
*
|
||||
* After this the profile holds the wallet — but NOT the repos' local cache — so the next
|
||||
* application session over it hits the broker-only cold-start.
|
||||
*
|
||||
* The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password
|
||||
* can then be tested by reading it off its own screen and passing it here, which is the only
|
||||
* way to tell that what the barrier shows is what actually opens the file. A default would
|
||||
* make that step untestable: the import would succeed on a barrier showing anything at all,
|
||||
* including nothing.
|
||||
*/
|
||||
export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void> {
|
||||
await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" });
|
||||
// Let the application render and attach the file input (uploading too early → EncryptionError).
|
||||
await page.waitForTimeout(3000);
|
||||
await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 });
|
||||
await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath);
|
||||
const passwordInput = page.locator(WALLET_IMPORT.passwordField).first();
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
||||
await passwordInput.fill(password);
|
||||
await passwordInput.press("Enter");
|
||||
const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm });
|
||||
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
||||
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
||||
}
|
||||
|
||||
/** The export page, bundled once per run. */
|
||||
let exportBundle: string | null = null;
|
||||
|
||||
function buildExportBundle(): string {
|
||||
if (exportBundle !== null) return exportBundle;
|
||||
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js");
|
||||
const entry = path.join(__dirname, "wallet-export-page.ts");
|
||||
execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, {
|
||||
stdio: "pipe",
|
||||
cwd: __dirname,
|
||||
timeout: BUILD_MS,
|
||||
});
|
||||
exportBundle = fs.readFileSync(out, "utf-8");
|
||||
return exportBundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the wallet held by `ctx`'s profile as a `.ngw` file at `ngwPath`, and return its
|
||||
* size in bytes.
|
||||
*
|
||||
* Why an application's own suite needs this: a deployment that hands a wallet out — an access
|
||||
* barrier with a download link, say — must be tested against a REAL wallet. Serving a
|
||||
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
|
||||
* so the check that the link works cannot fail for the right reason.
|
||||
*/
|
||||
export async function exportWalletFile(
|
||||
ctx: BrowserContext,
|
||||
ngwPath: string,
|
||||
walletPassword: string,
|
||||
): Promise<number> {
|
||||
const bundle = buildExportBundle();
|
||||
const html =
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
|
||||
'<body><script type="module" src="/wallet-export-page.js"></script></body></html>';
|
||||
const { url, close } = await serveOnEphemeralPort((req, res) => {
|
||||
if (req.url === "/wallet-export-page.js") {
|
||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||
res.end(bundle);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
}
|
||||
});
|
||||
const page = await newPage("the wallet export", ctx);
|
||||
page.on("pageerror", () => {});
|
||||
try {
|
||||
const frame = await setupBrokerPage(page, url, walletPassword);
|
||||
await frame.waitForFunction(
|
||||
() => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined,
|
||||
{ timeout: EXPORT_PAGE_MS },
|
||||
);
|
||||
await frame.waitForFunction(
|
||||
() => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected",
|
||||
{ timeout: EXPORT_CONNECT_MS },
|
||||
);
|
||||
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of
|
||||
// the two ways this harness used to hang for ever.
|
||||
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
|
||||
frame.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as { __ngWalletExport: { file(): Promise<ExportedWallet> } }
|
||||
).__ngWalletExport.file(),
|
||||
),
|
||||
);
|
||||
fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64"));
|
||||
return exported.len;
|
||||
} finally {
|
||||
await closeQuietly("the wallet export page", () => page.close());
|
||||
close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user