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:
@@ -30,15 +30,49 @@
|
||||
|
||||
import { type BrowserContext, type Frame, type Page } from "playwright";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureWallet, launchWalletContext, setupBrokerPage } from "./broker";
|
||||
import {
|
||||
PROFILE_DIR,
|
||||
WALLET_PASSWORD,
|
||||
closeContext,
|
||||
completeBrokerLogin,
|
||||
ensureWallet,
|
||||
exportWalletNgw,
|
||||
importWalletViaFile,
|
||||
launchCleanProfileContext,
|
||||
launchWalletContext,
|
||||
newPage,
|
||||
serveOnEphemeralPort,
|
||||
setupBrokerPage,
|
||||
} from "./broker";
|
||||
import { armSuiteDeadline, closeQuietly, within } from "./deadline";
|
||||
import { acquireRunLock } from "./run-lock";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook");
|
||||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "notebook.js");
|
||||
/** Where the application configures its shared wallet (`examples/notebook/app.ts`). */
|
||||
const WALLET_PATH = "/shared-wallet.ngw";
|
||||
|
||||
// ── bounds ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The batch's wall clock. A healthy run measures ~1.5 min; the longest single wait in it is
|
||||
* the newcomer's cold first render, bounded at 3 min. Fifteen is an order of magnitude of
|
||||
* room and still an answer inside a coffee break — which is the point, since what this
|
||||
* replaces is a run that was killed by hand at 68 minutes having printed nothing.
|
||||
*/
|
||||
const SUITE_DEADLINE_MS = 15 * 60 * 1000;
|
||||
/**
|
||||
* One journey. The longest (the newcomer's) does a wallet download, an import into a cold
|
||||
* profile, a broker round-trip and a first render — measured under 2 min, bounded at 6.
|
||||
*/
|
||||
const JOURNEY_MS = 6 * 60 * 1000;
|
||||
/** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */
|
||||
const SIGN_IN_MS = 3 * 60 * 1000;
|
||||
|
||||
// ── reporting ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,10 +82,17 @@ function check(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
/**
|
||||
* A journey, under its own deadline.
|
||||
*
|
||||
* The bound is what makes the catch honest: catching everything and recording a FAIL is
|
||||
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
|
||||
* and that is what three killed runs looked like from the outside.
|
||||
*/
|
||||
async function journey(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
console.log(`\n── ${name} ──`);
|
||||
try {
|
||||
await fn();
|
||||
await within(`the journey "${name}"`, JOURNEY_MS, fn);
|
||||
} catch (e: any) {
|
||||
check(name, false, "threw: " + String(e?.message ?? e));
|
||||
}
|
||||
@@ -67,24 +108,54 @@ function buildApp(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function serveApp(): Promise<{ url: string; close: () => void }> {
|
||||
/**
|
||||
* Serve the application the way a deployment would — including the two things the access
|
||||
* gate hands a first-time user, which nothing served before.
|
||||
*
|
||||
* The **wallet file**: `app.ts` configures `fileUrl: "/shared-wallet.ngw"`, and no such
|
||||
* file exists in the repository (nor should one — a wallet is never committed). So the
|
||||
* deployment supplies it, and here that is this server, from bytes exported at test time.
|
||||
*
|
||||
* The **password**: `app.ts` reads it from `__NOTEBOOK_WALLET_PASSWORD__`, resolved "at
|
||||
* its own build" as `SharedWalletConfig` requires — the library reads no environment. The
|
||||
* inline script below is that resolution; without it the barrier displays an empty
|
||||
* password and no import can succeed.
|
||||
*
|
||||
* And an unknown path now 404s instead of returning the page. That is not tidiness: the
|
||||
* catch-all made `/shared-wallet.ngw` answer 200 with the application's own HTML, so a
|
||||
* download of a file NOBODY served looked like a perfectly good download. The check that
|
||||
* the link resolves could not have failed.
|
||||
*/
|
||||
function serveApp(walletFile: Buffer, walletPassword: string): Promise<{ url: string; close: () => void }> {
|
||||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||
const html = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8");
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url ?? "").startsWith("/app.js")) {
|
||||
const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8");
|
||||
const moduleTag = '<script type="module" src="/app.js"></script>';
|
||||
if (!page.includes(moduleTag)) {
|
||||
throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`);
|
||||
}
|
||||
const html = page.replace(
|
||||
moduleTag,
|
||||
`<script>globalThis.__NOTEBOOK_WALLET_PASSWORD__ = ${JSON.stringify(walletPassword)};</script>\n ${moduleTag}`,
|
||||
);
|
||||
return serveOnEphemeralPort((req, res) => {
|
||||
const route = (req.url ?? "/").split("?")[0];
|
||||
if (route === "/app.js") {
|
||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||
res.end(bundle);
|
||||
} else {
|
||||
} else if (route === WALLET_PATH) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": 'attachment; filename="shared-wallet.ngw"',
|
||||
});
|
||||
res.end(walletFile);
|
||||
} else if (route === "/" || route === "/index.html") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── one actor = one page, signed in as one identity ─────────────────────────
|
||||
@@ -101,17 +172,19 @@ interface Actor {
|
||||
}
|
||||
|
||||
async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<Actor> {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||||
return within(`${id} to sign in`, SIGN_IN_MS, async () => {
|
||||
const page = await newPage(id, ctx);
|
||||
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||||
});
|
||||
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
|
||||
// resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees
|
||||
// the barrier. Here it is also how the suite signs an actor in without typing.
|
||||
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
|
||||
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
|
||||
return { id, frame, page };
|
||||
});
|
||||
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
|
||||
// resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees
|
||||
// the barrier. Here it is also how the suite signs an actor in without typing.
|
||||
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
|
||||
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
|
||||
return { id, frame, page };
|
||||
}
|
||||
|
||||
// ── the acts, expressed as the application expresses them ───────────────────
|
||||
@@ -206,28 +279,62 @@ async function readMessages(a: Actor, title: string): Promise<string> {
|
||||
/** Reload the page: what a user does, and what makes a durable fact distinguishable
|
||||
* from one that only lived in this tab's memory. */
|
||||
async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise<Actor> {
|
||||
await a.page.close().catch(() => {});
|
||||
await closeQuietly(`${a.id}'s previous page`, () => a.page.close());
|
||||
return signIn(ctx, appUrl, a.id);
|
||||
}
|
||||
|
||||
// ── the journeys ────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Before anything touches the shared profile: this batch is about to DELETE it (see
|
||||
// `ensureWallet`), so a second run alive right now would be destroyed by this one.
|
||||
acquireRunLock("the applicative suite (e2e/notebook.ts)", PROFILE_DIR);
|
||||
armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS);
|
||||
console.log("[e2e/app] building the example application...");
|
||||
buildApp();
|
||||
console.log("[e2e/app] ensuring the batch wallet...");
|
||||
await ensureWallet();
|
||||
const { url, close: closeServer } = await serveApp();
|
||||
console.log(`[e2e/app] application served at ${url}`);
|
||||
|
||||
const t = Date.now().toString(36);
|
||||
const ALICE = `alice-${t}`;
|
||||
const BOB = `bob-${t}`;
|
||||
// The wallet the barrier hands out, and the newcomer's download of it. Both under a
|
||||
// temp dir, removed at the end: a wallet file is an identity, and one must never be
|
||||
// committed — `*.ngw` is gitignored besides, which is the belt to this brace.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-"));
|
||||
const sharedWalletFile = path.join(tmpDir, "shared-wallet.ngw");
|
||||
|
||||
let ctx: BrowserContext | null = null;
|
||||
let closeServer: (() => void) | null = null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
ctx = await launchWalletContext();
|
||||
// THIS batch's wallet, as bytes. It has to be the very one the other actors live in —
|
||||
// one shared wallet hosts every identity — and its bytes exist only inside the broker
|
||||
// iframe, so a harness page is what fetches them.
|
||||
//
|
||||
// In a context of its OWN, opened and closed before the actors'. When it shared
|
||||
// theirs, one run had all four existing journeys collapse together — Alice and Bob
|
||||
// signed in, then every `[data-testid=…]` vanished from their frames, which is what a
|
||||
// RELOADED iframe looks like from here. Observed once and not reproduced, so the
|
||||
// mechanism is a suspicion, not a finding: closing a broker page may end the session
|
||||
// its profile-mates are using. Cheap to avoid, so avoided — the actors inherit
|
||||
// nothing. Sequential contexts over the one profile dir; never two at once.
|
||||
console.log("[e2e/app] exporting the wallet the barrier hands out...");
|
||||
const exportCtx = await launchWalletContext("wallet-export");
|
||||
let walletSize = 0;
|
||||
try {
|
||||
walletSize = await exportWalletNgw(exportCtx, sharedWalletFile);
|
||||
} finally {
|
||||
await closeContext("wallet-export", exportCtx);
|
||||
}
|
||||
|
||||
ctx = await launchWalletContext("actors");
|
||||
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD);
|
||||
closeServer = served.close;
|
||||
const url = served.url;
|
||||
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`);
|
||||
|
||||
const alice = await signIn(ctx, url, ALICE);
|
||||
check("Alice signs in and the application knows who she is", true, `who=${ALICE}`);
|
||||
const bob = await signIn(ctx, url, BOB);
|
||||
@@ -302,9 +409,131 @@ async function main(): Promise<void> {
|
||||
check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60));
|
||||
check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60));
|
||||
});
|
||||
|
||||
// 5. The path no journey walked: somebody who holds NOTHING. No wallet in the
|
||||
// profile, no identifier anywhere, and the application's own address — not the one
|
||||
// `signIn()` builds, which appends `?ng-id=` and so makes the barrier resolve from
|
||||
// the URL and never appear. Two defects shipped green behind that shortcut: the
|
||||
// application handed the page to the broker BEFORE the barrier could show (a
|
||||
// first-time user landed on a login with no wallet and no way to get one), and the
|
||||
// file the barrier offers was served by nobody, so its link pointed at a 404.
|
||||
//
|
||||
// Its own browser profile, deliberately: a wallet already in the profile is the
|
||||
// other half of the same shortcut, and it is exactly what a first-time device
|
||||
// does not have.
|
||||
await journey("a first-time user, holding nothing, gets in through the barrier", async () => {
|
||||
const newcomer = `newcomer-${t}`;
|
||||
const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw");
|
||||
const fresh = await launchCleanProfileContext();
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
page = await fresh.ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("[newcomer pageerror]", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("[newcomer console]", m.text());
|
||||
});
|
||||
|
||||
// The address a link in an email gives: the application, nothing appended.
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
const gate = page.locator('[data-ng-eventually="access-gate"]');
|
||||
const identityField = page.locator('[data-testid="ng-identity-input"]');
|
||||
await identityField.waitFor({ state: "visible", timeout: 30000 });
|
||||
// Still on the application's own page — the hand-over has NOT happened. That is
|
||||
// the whole of the first defect: `init()` navigated first, so everything the
|
||||
// application did next ran in a document that no longer existed.
|
||||
check(
|
||||
"the barrier appears, and the page has not been handed to the broker yet",
|
||||
page.url().startsWith(url),
|
||||
page.url(),
|
||||
);
|
||||
|
||||
// Step 1 of the barrier: the wallet file. Captured through the browser's own
|
||||
// download, which is the only thing that can say whether the link RESOLVES.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download", { timeout: 30000 }),
|
||||
gate.locator("a[download]").click(),
|
||||
]);
|
||||
const failure = await download.failure();
|
||||
if (failure === null) await download.saveAs(downloaded);
|
||||
const bytes = fs.existsSync(downloaded) ? fs.statSync(downloaded).size : 0;
|
||||
check(
|
||||
"the barrier's download link serves a wallet file, not a 404",
|
||||
failure === null && bytes > 0,
|
||||
`failure=${failure ?? "none"} bytes=${bytes}`,
|
||||
);
|
||||
|
||||
// Step 2: the password, read off the screen the way a user reads it. An injected
|
||||
// value nobody looks at is a value that can be silently empty.
|
||||
const password = ((await gate.locator("code").first().textContent()) ?? "").trim();
|
||||
check("the barrier shows the password for the import", password.length > 0, password);
|
||||
|
||||
// Step 3: the wallet application, opened by the barrier's own link — a second tab,
|
||||
// which is what `target="_blank"` gives. Nothing proves the downloaded bytes ARE a
|
||||
// wallet except this import working, which is why the journey imports what it
|
||||
// downloaded and nothing else.
|
||||
const [walletPage] = await Promise.all([
|
||||
fresh.ctx.waitForEvent("page", { timeout: 30000 }),
|
||||
gate.locator('a[target="_blank"]').click(),
|
||||
]);
|
||||
await importWalletViaFile(walletPage, downloaded, password);
|
||||
await walletPage.close().catch(() => {});
|
||||
|
||||
// Step 4: the identifier, typed. That is what settles the identity — and what
|
||||
// `init()` then puts in the address bar before it hands the page over.
|
||||
await identityField.fill(newcomer);
|
||||
await page.locator('[data-testid="ng-identity-enter"]').click();
|
||||
|
||||
// The APPLICATION navigates, not the test; then the broker loads it back inside
|
||||
// its iframe. `setupBrokerPage` is deliberately not used here — it would
|
||||
// re-navigate and throw away the URL the application had just built.
|
||||
await page.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
||||
check("the application hands the page to the broker itself", /nextgraph\./.test(page.url()), page.url());
|
||||
const frame = await completeBrokerLogin(page, url);
|
||||
check(
|
||||
"the application comes back inside the broker iframe",
|
||||
frame !== page.mainFrame() && /nextgraph\./.test(page.mainFrame().url()),
|
||||
`top=${page.mainFrame().url().slice(0, 40)}… app=${frame.url().slice(0, 40)}…`,
|
||||
);
|
||||
|
||||
// THE assertion this journey exists for. The identifier crosses the round-trip in
|
||||
// the URL and nowhere else — storage is partitioned by top-level site. Lose it and
|
||||
// nothing throws: the iframe reads an empty identity, provisions a SECOND virtual
|
||||
// space, and the user lands somewhere empty that looks like a working application.
|
||||
check("the identifier survived the round-trip in the URL", frame.url().includes(`ng-id=${newcomer}`), frame.url());
|
||||
const arrived: Actor = { id: newcomer, frame, page };
|
||||
// A longer bound than `signIn`'s, and for a reason the other suite already
|
||||
// measured: this profile is COLD — a wallet just imported, no local copy of any
|
||||
// repo — so the first render waits on provisioning that round-trips the broker
|
||||
// per repo (`run.ts` bounds the same cold-start generously for the same reason).
|
||||
// A bound, not a sleep: it fails if the application never comes up.
|
||||
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
||||
const who = ((await frame.locator('[data-testid="who"]').textContent()) ?? "").trim();
|
||||
check("the application knows the newcomer as the identity he typed", who.includes(newcomer), who);
|
||||
check(
|
||||
"the barrier does not ask again inside the broker iframe",
|
||||
(await frame.locator('[data-testid="ng-identity-input"]').count()) === 0,
|
||||
);
|
||||
|
||||
// And it is a usable session, not merely a name on a screen.
|
||||
await writeNote(arrived, "protected", "Première note", "écrite juste après la barrière");
|
||||
const list = (await frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
||||
check(
|
||||
"the newcomer writes a note and reads it back, as himself",
|
||||
list.includes("Première note") && list.includes("écrite juste après la barrière"),
|
||||
list.replace(/\s+/g, " ").slice(0, 70),
|
||||
);
|
||||
} finally {
|
||||
if (page) await closeQuietly("the newcomer's page", () => page!.close());
|
||||
await closeContext("clean-profile", fresh.ctx);
|
||||
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await ctx?.close().catch(() => {});
|
||||
closeServer();
|
||||
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
|
||||
// gone never resolves, and this `finally` is where that hang swallowed the summary.
|
||||
if (ctx) await closeContext("actors", ctx);
|
||||
closeServer?.();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
@@ -316,4 +545,9 @@ async function main(): Promise<void> {
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
void main();
|
||||
main().catch((e) => {
|
||||
// Anything the journeys did not catch — a refused run lock, a browser lost during setup.
|
||||
// Reported and exited, never left to become an unhandled rejection nobody sees.
|
||||
console.error("[e2e/app] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user