diff --git a/.project/concepts/e2e-harness/_debt.md b/.project/concepts/e2e-harness/_debt.md new file mode 100644 index 0000000..167d248 --- /dev/null +++ b/.project/concepts/e2e-harness/_debt.md @@ -0,0 +1,8 @@ +# Doc-debt — e2e-harness + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/e2e/measure.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/notebook.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/packages/polyfill/e2e/broker.ts b/packages/polyfill/e2e/broker.ts index b488a65..6db4e91 100644 --- a/packages/polyfill/e2e/broker.ts +++ b/packages/polyfill/e2e/broker.ts @@ -49,10 +49,21 @@ export const WALLET_PASSWORD = "ng-eventually-e2e"; const BUILD_MS = 60_000; /** Launching a browser is local too — 30s is Playwright's own default, doubled. */ const LAUNCH_MS = 60_000; -/** Opening a page in a live browser is instant; a minute means the browser is not answering. */ -const NEW_PAGE_MS = 60_000; -/** The whole wallet export measures ~7s against the real broker; two minutes is a hang. */ -const EXPORT_MS = 120_000; +/** + * Opening a page in a live browser is instant — measured 0.0–0.1s over a batch. Bounded at + * 10s, which is a hundred times the measurement and still fails while a reader is watching. + * Exported because the applicative suite has to know it: 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; +/** + * The whole wallet export measures ~7s against the real broker. Bounded at 60s ≈ 8x. + * + * Was two minutes, and that cost the batch of 2026-08-16 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 this runs in the SETUP, ahead of every journey. + */ +const EXPORT_MS = 60_000; const ENTRY = path.resolve(__dirname, "polyfill-entry.ts"); const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js"); @@ -538,9 +549,27 @@ export async function setupBrokerPage(page: Page, appUrl: string): Promise Promise): * cost the hours this module exists to stop spending. * * `unref`ed, so a healthy run is never held open by its own watchdog. + * + * `thenReport` lets a suite print its own summary before the process goes — without it a run + * that trips this watchdog reports its waits and then vanishes, so its check count is zero + * and comparable with nothing. (`notebook.ts` passes its `finish`.) */ -export function armSuiteDeadline(suite: string, ms: number): void { +export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void { const startedAt = Date.now(); const timer = setTimeout(() => { console.error( @@ -188,6 +192,11 @@ export function armSuiteDeadline(suite: string, ms: number): void { } } console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`); + // A suite that can still say what it did and did not verify must be allowed to say it — + // otherwise the watchdog, whose whole purpose is to replace a silent kill with a report, + // produces its own silent kill. `thenReport` is expected to exit; the line below is the + // fallback for a caller that has nothing to report. + if (thenReport !== undefined) thenReport(); process.exit(1); }, ms); timer.unref?.(); diff --git a/packages/polyfill/e2e/measure.ts b/packages/polyfill/e2e/measure.ts new file mode 100644 index 0000000..2eed882 --- /dev/null +++ b/packages/polyfill/e2e/measure.ts @@ -0,0 +1,113 @@ +/** + * What each bounded operation ACTUALLY takes — the measurement every bound is sized from. + * + * ── Why a bound needs its measurement kept beside it ───────────────────────── + * A bare number teaches nothing and rots in silence. "180 seconds" cannot be judged: is it + * ten times the normal duration, or a hundred? Only one of those is a bound; the other is a + * hang dressed up as one. The suite this module serves had a sign-in bounded at 3 minutes + * for an operation that measures 1.4s, so its single job — turning a hang into a named + * failure fast — was done fifty times too slowly to be worth anything. + * + * So every bound in these harnesses is written as `measured normal → bound → margin`, and + * this module is how the "measured normal" half is obtained and re-obtained. Run any suite + * with `E2E_TIMINGS=1` and it prints, at the end, what each named operation took and how + * much headroom its bound still has. A future reader who suspects a number has gone stale + * does not have to believe this file's comments: they can re-run the measurement. + * + * Passive by default — a `Date.now()` per wait, and nothing printed unless asked. + */ + +/** One observation of one named operation. */ +interface Sample { + readonly ms: number; + /** Sizing a bound from a FAILED attempt would size it from the bound itself. */ + readonly ok: boolean; + /** The bound in force, so the report can show the headroom rather than make one guess it. */ + readonly bound: number; +} + +const samples = new Map(); + +export function record(what: string, ms: number, ok: boolean, bound: number): void { + const seen = samples.get(what); + if (seen === undefined) samples.set(what, [{ ms, ok, bound }]); + else seen.push({ ms, ok, bound }); +} + +/** + * Run a wait under `bound`, recording what it took under the stable name `what`. + * + * The bound is handed TO the task rather than raced against it, deliberately: Playwright's + * own timeout reports the call log ("waiting for locator(…)"), and a race would replace that + * with a message naming only the enclosure. What this adds is the measurement and a stable + * name — not a second, competing deadline. + * + * The name must be stable across runs (no identifiers, no ports) or the table fragments into + * one row per run and measures nothing. + */ +export async function measured(what: string, bound: number, task: (ms: number) => Promise): Promise { + const startedAt = Date.now(); + let ok = false; + try { + const out = await task(bound); + ok = true; + return out; + } finally { + record(what, Date.now() - startedAt, ok, bound); + } +} + +/** Whether the caller asked for the table. */ +export function timingsWanted(): boolean { + return (process.env.E2E_TIMINGS ?? "") !== ""; +} + +function fmt(ms: number): string { + return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Print what was measured: per operation, the healthy observations and the headroom its + * bound has over the SLOWEST of them. + * + * Failed attempts are counted but excluded from the statistics, because an operation that + * hit its bound measures the bound and not the operation — feeding that back into the sizing + * is how a bound ratchets upward for ever, one bad run at a time. + */ +export function printTimings(): void { + if (samples.size === 0) { + console.log("\n── measured durations ── nothing was recorded."); + return; + } + const rows = [...samples.entries()].map(([what, all]) => { + const good = all.filter((s) => s.ok).map((s) => s.ms).sort((a, b) => a - b); + const bound = all[all.length - 1]!.bound; + const failed = all.length - good.length; + return { + what, + n: good.length, + min: good.length === 0 ? null : good[0]!, + max: good.length === 0 ? null : good[good.length - 1]!, + bound, + failed, + }; + }); + const width = Math.max(...rows.map((r) => r.what.length), 9); + console.log("\n── measured durations (E2E_TIMINGS) ─────────────────────────────────────────"); + console.log( + ` ${"operation".padEnd(width)} ${"n".padStart(3)} ${"min".padStart(6)} ${"max".padStart(6)}` + + ` ${"bound".padStart(6)} headroom failed`, + ); + for (const r of rows) { + const headroom = r.max === null || r.max === 0 ? "—" : `${(r.bound / r.max).toFixed(0)}x`; + console.log( + ` ${r.what.padEnd(width)} ${String(r.n).padStart(3)} ` + + `${(r.min === null ? "—" : fmt(r.min)).padStart(6)} ${(r.max === null ? "—" : fmt(r.max)).padStart(6)} ` + + `${fmt(r.bound).padStart(6)} ${headroom.padStart(8)} ${r.failed === 0 ? "" : String(r.failed)}`, + ); + } + console.log( + " (statistics are over SUCCESSFUL attempts only: a wait that hit its bound measures\n" + + " the bound, and sizing the next bound from it ratchets upward for ever.)", + ); +} diff --git a/packages/polyfill/e2e/notebook.ts b/packages/polyfill/e2e/notebook.ts index 47e621c..0d47937 100644 --- a/packages/polyfill/e2e/notebook.ts +++ b/packages/polyfill/e2e/notebook.ts @@ -35,6 +35,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { + BROKER_ROUND_TRIP_MS, + NEW_PAGE_MS, PROFILE_DIR, WALLET_PASSWORD, closeContext, @@ -49,6 +51,7 @@ import { setupBrokerPage, } from "./broker"; import { armSuiteDeadline, closeQuietly, within } from "./deadline"; +import { measured, printTimings, timingsWanted } from "./measure"; import { acquireRunLock } from "./run-lock"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,47 +63,389 @@ 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. + * ── How every bound below is sized ────────────────────────────────────────── + * A bound exists to turn a hang into a NAMED failure quickly. That gives it two jobs, and + * both are lost by picking a comfortable-looking round number: + * + * 1. A LEAF bound — one that wraps a single wait — is sized from that wait's own MEASURED + * duration, times a margin. The measurement is written beside it, so a reader can judge + * whether it still holds; `E2E_TIMINGS=1` re-prints all of them (see `measure.ts`), which + * is where these numbers came from and how the next reader will replace them. A bound + * fifty times the normal duration is not a bound: it is a three-minute freeze that + * reports at the end what a fifteen-second one would have reported at the start. + * + * 2. An ENCLOSING bound — one that wraps several bounded steps — must be at least the SUM + * of the bounds it encloses. This is not a margin, it is a correctness condition. Set it + * lower and it always fires FIRST, so every failure inside it is reported as "the + * enclosure timed out" and the step that actually hung is never named. That is precisely + * what `SIGN_IN_MS` used to do: at 3 min it sat below its own steps' bounds (60s + 30s + + * 120s + 60s = 4.5 min), so a sign-in failure could only ever say "bob-… to sign in" — + * naming the journey and not one of the four things it was doing. VERIFIED 2026-08-16 + * from the run-1 log, whose sole diagnostic was that sentence. + * + * So the enclosing bounds here are deliberately NOT "the measured normal times a margin" — + * they are the sum of their steps, and each is annotated with the sum it comes from. Shrink + * a step's bound and the enclosure shrinks with it; that is the lever, not the enclosure. + */ + +// `NEW_PAGE_MS` and `BROKER_ROUND_TRIP_MS` are IMPORTED from `broker.ts`, not restated here: +// both operations bound themselves there (75s = the navigation plus the ceremony), and a copy +// set lower would fire first and replace the ceremony's failure message — the screen it +// recognised, the trail, every frame, the page's own text — with a sentence naming only the +// enclosure. Measured 1.3–2.8s for an actor and 1.6–1.7s on a barrier passage (3 runs). +/** + * The application's first render — `[data-testid="who"]` gaining text — on a WARM profile + * whose repos are already local. Measured 4.9–7.4s (12 samples, 3 runs). Bounded at 45s ≈ 6x + * the SLOWEST measured. + * + * THE WIDEST-VARYING OPERATION IN THE FILE, and the margin is sized for that spread rather + * than for the median. Note how far apart the two reliable figures are: a whole sign-in has + * been measured at 1.4s end to end, while this one step inside it measures 4.9–7.4s here — + * so the same operation has ranged over roughly an order of magnitude across hosts and days. + * A bound sized at "6x the median" would therefore be a bound sized to fail on a + * slow-but-healthy broker, which is the one thing a bound must never do. + * + * It was set to 30s on the first pass and the measurement then said 7.4s, i.e. 4x — too thin + * for the widest-varying wait in the file, so it was raised. That is the sizing rule working: + * measure, then size, then re-measure and correct. + */ +const FIRST_RENDER_MS = 45_000; +/** + * The same first render on a COLD profile — a wallet just imported, no local copy of any + * repo, so it waits on provisioning that round-trips the broker once per repo. Measured + * 8.9–12s (6 samples, 3 runs). Bounded at 90s ≈ 7x: the widest margin in the file and + * deliberately so, because this is the operation whose duration depends on how much the + * broker has to build, and the one that has actually expired in the field. It reports what + * the frame AND the broker page were showing when it fails ({@link reportStalledRender}) — a + * bound this wide has to earn it by explaining itself. + */ +const COLD_FIRST_RENDER_MS = 90_000; +/** A list re-rendering after a shelf change. Measured 0.0s (6 samples); bounded at 15s — a + * round-trip-free DOM swap, so a wide margin costs a healthy run nothing. */ +const LIST_SETTLE_MS = 15_000; +/** A note written and read back — one broker write. Measured 0.8–1.3s (15 samples); bounded + * at 30s ≈ 23x. */ +const WRITE_NOTE_MS = 30_000; +/** The application answering with a note it fetched by reference. Measured 0.0s for the + * answer itself and 1.8s when it is a note's messages; bounded at 30s ≈ 17x. Includes the + * case where the answer is legitimately "unreadable". */ +const ANSWER_MS = 30_000; +/** A capability deposited for another identity, a note opened to messages, or a message left + * — one inbox write each. Measured 0.2–1.3s; bounded at 30s ≈ 23x. */ +const DEPOSIT_MS = 30_000; +/** The access gate painting on a cold profile — static markup, no broker involved. Measured + * 0.0s (8 samples); bounded at 20s. */ +const BARRIER_MS = 20_000; +/** The wallet file arriving over the browser's own download machinery (measured 0.0–0.1s), + * and the wallet application opening in its second tab (measured 0.2–0.3s). Bounded at 20s ≈ 70x. */ +const BARRIER_TAB_MS = 20_000; +/** The application navigating to the broker ITSELF once the identity is settled. Measured + * 0.8–2.9s (8 samples); bounded at 30s ≈ 10x. */ +const HANDOVER_MS = 30_000; +/** Importing the downloaded wallet into a cold profile. Measured 12s, consistently (6 + * samples, no spread) — of which 8s is a fixed settle inside `importWalletViaFile`, which is + * why it barely varies. Bounded at 60s ≈ 5x. */ +const WALLET_IMPORT_MS = 60_000; +/** Closing the wallet application's tab. Measured under 0.1s. Bounded at 15s and reported + * rather than thrown, like every other close: `page.close()` carries no timeout of its own, + * and a close that never returns is the exact shape of the hang `deadline.ts` was written + * for — this was the last one in these journeys still going unbounded. */ +const WALLET_TAB_CLOSE_MS = 15_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. */ +const FRAME_PROBE_MS = 10_000; + +/** + * Signing an actor in. ENCLOSING (rule 2 above), and COMPUTED rather than written down: it is + * the sum of the three steps it encloses plus a margin, so it cannot silently fall below them + * when one of them is retuned. A comment saying "keep this above the sum" is a discipline; an + * addition is a mechanism, and the mechanism is what survives the next edit. + * + * A healthy sign-in measures 1.4s on the no-password path and 3.4s with the password. This + * bound is deliberately NOT sized from that: sized from the measurement it would fire before + * its own steps could, and every sign-in failure in this suite would go back to reporting + * "bob-… to sign in" and naming none of the four things it was doing. + */ +const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + FIRST_RENDER_MS + 10_000; +/** + * One journey. ENCLOSING — and the one place where rule 2 above is deliberately NOT applied, + * which is worth saying out loud rather than leaving as an inconsistency. + * + * The sum of the longest journey's steps is ~11.5 min (the returning visitor makes two full + * barrier passages, each of barrier + download + tab + import + hand-over + round-trip + cold + * render + a write). A journey bounded at 11.5 min would outlast the SUITE's own clock, so a + * single hung journey would take the summary down with it — the enclosure rule would be + * satisfied and the run would report less, not more. + * + * It is sized from the measurement instead, and it can afford to be: every step inside a + * journey already carries its own bound and names itself, so this catches only a hang in code + * no step wraps. Measured on a green run: 44s for the returning visitor and 28s for the + * newcomer, the two longest; the rest are under 16s. Bounded at 4 min ≈ 5.5x the longest. + */ +const JOURNEY_MS = 4 * 60 * 1000; +/** + * The batch's wall clock — the last resort behind every bound above, for the wait nobody + * wrapped. A healthy run measures 3.2 min; seven journeys at their own bound would be far + * more than this, and that is intended: this is not the sum of the journeys, it is the point + * past which a run has stopped being a measurement of anything. What it replaces is a run + * killed by hand at 68 minutes having printed nothing. */ const SUITE_DEADLINE_MS = 15 * 60 * 1000; + +// ── what this suite reports ───────────────────────────────────────────────── + /** - * 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. - * The returning visitor's does that twice over and measures 36s, so it fits the same bound - * with room to spare; a bound of its own was tried and dropped, because on a healthy host - * nothing justified it and an 11-minute journey can outlast the SUITE's own deadline — - * which prints no summary at all. A journey that overruns THIS is a hang or a sick host, - * and both are worth hearing about rather than absorbing. + * Every journey, and every check each one reports. + * + * ── Why the checks are declared HERE and not at the call site ──────────────── + * So the run's total is known BEFORE the first browser is launched. A journey that declares + * its checks inside itself can still take them off the report by dying in the SETUP that + * precedes it — VERIFIED 2026-08-16: the wallet export hung, and the run printed `fatal:` + * and left, with no summary, no checks, and nothing a previous run could be compared to. + * Read off this table, the arithmetic survives any death: whatever happens, every journey + * contributes its checks plus its "ran to the end" row, so the total is a property of this + * file and a difference between two runs is always a real difference. + * + * It doubles as the suite's table of contents, which is the other reason to keep it whole + * and in execution order. */ -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; +const SUITE: readonly { readonly name: string; readonly checks: readonly string[] }[] = [ + { + name: "Alice and Bob each sign in, in their own space", + checks: ["Alice signs in and the application knows who she is", "Bob signs in, in his own space"], + }, + { + name: "Bob reads Alice's public note from its reference alone", + checks: [ + "the application SHOWS the reference, so a human can circulate it", + "Bob reads it holding nothing but that reference", + "the reference carried no key", + ], + }, + { + name: "Alice's protected note stays shut until she gives Bob the key", + checks: ["Bob can NAME it and reads nothing of it", "after Alice shares it, the same reference opens it"], + }, + { + name: "Bob leaves a message on Alice's note, and only Alice reads it", + checks: ["Alice reads the message left on her note"], + }, + { + name: "each actor's list holds their own notes, and no one else's", + checks: [ + "Alice sees her own notes", + "Bob sees HIS own note — the control that lets the next check fail", + "Bob's list does not contain Alice's note", + "Alice's list does not contain Bob's note", + ], + }, + { + name: "a first-time user, holding nothing, gets in through the barrier", + checks: [ + "the barrier appears, and the page has not been handed to the broker yet", + "the barrier's download link serves a wallet file, not a 404", + "the barrier shows the password for the import", + "the application hands the page to the broker itself", + "the application comes back inside the broker iframe", + "the identifier survived the round-trip in the URL", + "the application knows the newcomer as the identity he typed", + "the barrier does not ask again inside the broker iframe", + "the newcomer writes a note and reads it back, as himself", + ], + }, + { + name: "a returning visitor meets the barrier again, prefilled, and keeps their space", + checks: [ + "the barrier still hands out the wallet without asking whether they have it", + "the barrier appears again, on a visit where the identifier is already known", + "and it arrives prefilled — one click, nothing to retype", + "confirming the prefilled field is what hands the page over", + "the round-trip brings them back as the same identity", + "and into the same space — the note from the first visit is still theirs", + ], + }, +]; + +/** The journeys that have already been reported, so {@link finish} knows what is missing. */ +const reported = new Set(); + +/** Module level, not `main`'s local: {@link finish} reports the elapsed time on the fatal + * path too, and that path can be reached before `main` has got as far as a local. */ +const suiteStartedAt = Date.now(); // ── reporting ─────────────────────────────────────────────────────────────── type Check = { name: string; ok: boolean; detail?: string }; const results: Check[] = []; -function check(name: string, ok: boolean, detail?: string): void { + +/** + * The checks the journey in flight has DECLARED and not yet reported — `null` between + * journeys. + * + * ── Why a journey declares its checks up front ─────────────────────────────── + * Because otherwise the run's check TOTAL is a function of how far it got. A journey that + * dies halfway takes its unreported checks with it and simply never mentions them, so three + * runs of the same suite reported 24, 26 and 27 checks (VERIFIED 2026-08-16, runs 1–3) — and + * a total that moves cannot be compared to anything. Worse, the checks that vanished are the + * ones nobody looked for: silence reads as absence, not as failure. + * + * Declared, the arithmetic is fixed before the run starts. Every journey contributes exactly + * `checks.length + 1` rows whatever happens to it, so the total is a property of the SUITE + * and a difference between two runs is always a real difference. + */ +let outstanding: Set | null = null; + +function record(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. + * Report a check. Its name must be one the journey declared, and each may be reported once. + * + * Both rules are enforced by throwing rather than by tolerating, because either violation + * silently breaks the arithmetic the declaration exists to fix — an undeclared name adds a + * row no other run has, a repeated one consumes a row that then reads as "not reached". A + * throw here fails the journey it happens in and says exactly what is wrong with it, which + * is a harness bug reported the same way as any other failure. + */ +function check(name: string, ok: boolean, detail?: string): void { + if (outstanding === null) { + throw new Error(`[e2e/app] the check ${JSON.stringify(name)} was reported outside any journey`); + } + if (!outstanding.delete(name)) { + throw new Error( + `[e2e/app] the check ${JSON.stringify(name)} was reported but its journey does not declare it ` + + "(or declares it once and reports it twice) — fix the journey's `checks` list", + ); + } + record(name, ok, detail); +} + +/** Why a journey cannot start, or `null` when it can. See {@link actorTrouble}. */ +type Prerequisite = () => Promise | (string | null); + +interface JourneySpec { + /** Must name an entry of {@link SUITE}, which is where its checks are declared. */ + readonly name: string; + /** + * What this journey needs from the ones before it. A prerequisite that is provably dead is + * reported as such INSTEAD of being driven — not to spare the journey, but because driving + * a closed page answers with "Target page, context or browser has been closed", a verdict + * that names the innocent `fill` and hides the journey that actually broke. + */ + readonly needs?: readonly Prerequisite[]; + readonly run: () => Promise; +} + +function firstLine(e: unknown): string { + return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)"; +} + +/** + * One journey, isolated: bounded, and unable to change the shape of the run's report. + * + * ── What "isolated" buys, and what it does not ─────────────────────────────── + * It does NOT mean a failure is absorbed — a contained failure is still a failure and is + * still counted, here as every one of the journey's declared checks plus the "ran to the end" + * row. What it means is that the journey's failure cannot take the following journeys' checks + * off the report, cannot leave THEM reporting a timeout that names the wrong suspect, and + * cannot end the run before its summary. * * 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. + * + * The last row, `ran to the end`, is not decoration either. Without it a journey that throws + * AFTER reporting its last check would report no failure at all, since there would be no + * unreached check left to carry the reason. */ -async function journey(name: string, fn: () => Promise, boundMs = JOURNEY_MS): Promise { - console.log(`\n── ${name} ──`); - try { - await within(`the journey "${name}"`, boundMs, fn); - } catch (e: any) { - check(name, false, "threw: " + String(e?.message ?? e)); +async function journey(spec: JourneySpec): Promise { + console.log(`\n── ${spec.name} ──`); + const planned = SUITE.find((j) => j.name === spec.name); + if (planned === undefined) { + throw new Error(`[e2e/app] the journey ${JSON.stringify(spec.name)} is not in SUITE — add it, or fix the name`); } + const declared = new Set(planned.checks); + if (declared.size !== planned.checks.length) { + throw new Error(`[e2e/app] SUITE declares the same check twice under "${spec.name}"`); + } + reported.add(spec.name); + const startedAt = Date.now(); + let why: string | null = null; + + const blocked = (await Promise.all((spec.needs ?? []).map(async (needed) => needed()))).filter( + (r): r is string => r !== null, + ); + if (blocked.length > 0) { + why = `it could not start: ${blocked.join("; ")}`; + console.error(` [blocked] ${why}`); + } else { + outstanding = declared; + try { + await within(`the journey "${spec.name}"`, JOURNEY_MS, spec.run); + } catch (e) { + why = firstLine(e); + // In full, and to stderr: the one-liner below is what the report carries, and it is + // never the whole of a Playwright call log or a broker login trail. + console.error(` [threw] ${String((e as Error)?.stack ?? e)}`); + } finally { + outstanding = null; + } + } + + for (const name of declared) { + record(name, false, why === null ? "the journey ended without reporting it" : `not reached — ${why}`); + } + record( + `the journey "${spec.name}" ran to the end`, + why === null, + why ?? `${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + ); +} + +/** + * Report everything this run did not get to, print the summary, and leave. + * + * The journeys that never ran are read off {@link SUITE}, so a run that died in its setup + * reports exactly the same number of checks as one that finished — all of them failed, and + * each saying why. That is the whole point of a fixed total: "24 checks" and "27 checks" are + * not two results of the same suite, they are two different suites, and comparing them + * quietly compares nothing. + */ +function finish(fatal: string | null): never { + for (const planned of SUITE) { + if (reported.has(planned.name)) continue; + const why = fatal === null ? "the suite ended before this journey ran" : `the suite died first: ${fatal}`; + for (const name of planned.checks) record(name, false, `not reached — ${why}`); + record(`the journey "${planned.name}" ran to the end`, false, why); + } + + // The measurement every bound in this file is sized from, on request. Printed BEFORE the + // summary so the summary stays the last line — which is what a reader and a `tail` look at. + if (timingsWanted()) printTimings(); + + const failed = results.filter((r) => !r.ok); + if (failed.length > 0) { + console.log("\n── what failed ──"); + for (const r of failed) console.log(` ${r.name}${r.detail ? " — " + r.detail : ""}`); + } + const minutes = ((Date.now() - suiteStartedAt) / 60000).toFixed(1); + console.log( + `\n══ Application e2e summary: ${results.length - failed.length} passed, ${failed.length} failed, ` + + `${results.length} total — ${minutes} min ══`, + ); + process.exit(failed.length === 0 ? 0 : 1); +} + +/** + * A named step that is both measured and bounded, for an operation carrying no timeout of + * its own — `evaluate`, `close`, anything of ours. Where the operation DOES take a timeout + * (every Playwright locator wait), `measured` is used directly instead and the bound is + * handed to Playwright, so its call log survives into the failure message. + */ +function step(what: string, ms: number, task: () => Promise): Promise { + return measured(what, ms, (bound) => within(what, bound, task)); } // ── build + serve the application, exactly as a deployment would ──────────── @@ -176,26 +521,186 @@ interface Actor { page: Page; } +/** + * Why `a` cannot be driven, or `null` when it can. + * + * Checked rather than assumed because the alternative is what run 1 reported: a journey + * whose actor had been left holding a closed page failed on `fill: Target page, context or + * browser has been closed` — a verdict naming its own innocent `fill` and saying nothing + * about the journey, three journeys earlier, that closed the page. + */ +async function actorTrouble(id: string, a: Actor | null): Promise { + if (a === null) return `${id} never signed in`; + if (a.page.isClosed()) return `${id}'s page has been closed`; + if (a.frame.isDetached()) return `${id}'s application frame is detached`; + // The third state, and the one that actually happens: a frame that is attached, on the + // right URL, and holds NOTHING — what a RELOADED iframe looks like from here. VERIFIED + // 2026-08-16: Alice's frame reached it mid-run and the next three journeys each reported a + // 30s timeout on a different innocent selector (`selectOption`, `fill`, `click`), none of + // them naming the frame. `count()` answers 0 immediately instead of waiting for the + // element, so this probe cannot itself become the hang it exists to name. + const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () => + a.frame.locator('[data-testid="who"]').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; +} + +/** + * The actor a journey body is entitled to assume, having declared it in `needs`. + * + * Re-checked here rather than asserted with `!`, because `needs` runs before the journey and + * an actor can die DURING one — the reopen in journey 2 is exactly that window. A throw here + * fails the journey with the actor's true condition; a `!` would hand the body a corpse and + * let it report a timeout instead. + */ +async function must(id: string, a: Actor | null): Promise { + const trouble = await actorTrouble(id, a); + if (trouble !== null || a === null) throw new Error(`[e2e/app] ${trouble ?? `${id} is missing`}`); + return a; +} + +/** + * Everything worth knowing about a first render that never came. + * + * ── Why the TOP-LEVEL page is the decisive part ────────────────────────────── + * Because `completeBrokerLogin` returns as soon as the application's frame ATTACHES, which is + * not the same event as the broker having opened the wallet — it watches the frame precisely + * because the final screen never stops reading as `working` (`broker.ts`). So a render that + * stalls has two very different explanations, and only the broker's own screen tells them + * apart: if the top-level page still shows a login or a wallet list, the ceremony stopped + * driving a flow that had not finished, and the application inside is waiting for a session + * that is never coming; if it shows "Wallet opened for …", the broker did its half and the + * stall is the application's or the polyfill's. + * + * That distinction is the whole of the difference between a harness bug and a product bug, + * and the failure that prompted this reported neither — 180 seconds, no in-page error, and + * not one fact to reason from. + */ +async function reportStalledRender(label: string, page: Page, frame: Frame): Promise { + const lines = [ + ` [${label}] the application never rendered. What was on the page at that moment:`, + ` frame detached: ${frame.isDetached()} page closed: ${page.isClosed()}`, + ` the frame it waited in: ${frame.url() === "" ? "(blank)" : frame.url()}`, + ` the top-level page: ${page.mainFrame().url() === "" ? "(blank)" : page.mainFrame().url()}`, + ]; + for (const f of page.frames()) { + lines.push(` ${f === page.mainFrame() ? "top" : "sub"} frame: ${f.url() === "" ? "(blank)" : f.url()}`); + } + const read = async (what: string, target: Frame): Promise => { + try { + const seen = await step(`${what} to describe itself`, FRAME_PROBE_MS, () => + target.evaluate(() => { + const who = document.querySelector('[data-testid="who"]'); + const gate = document.querySelector('[data-ng-eventually="access-gate"]'); + return { + who: who === null ? "(absent)" : JSON.stringify(who.textContent ?? ""), + gate: gate === null ? "(absent)" : "(showing)", + body: (document.body === null ? "" : document.body.innerText).replace(/\s+/g, " ").slice(0, 400), + }; + }), + ); + lines.push( + ` ${what} — [data-testid="who"]: ${seen.who} access gate: ${seen.gate}`, + ` ${what} — showing: ${seen.body === "" ? "(nothing at all)" : seen.body}`, + ); + } catch (probe) { + lines.push(` ${what} could not be read: ${firstLine(probe)}`); + } + }; + await read("the application frame", frame); + // The broker's screen, in its own words. THIS is the line that says whether the sign-in + // ceremony actually finished. + await read("the broker page", page.mainFrame()); + console.error(lines.join("\n")); +} + +/** + * Wait for the application's first render — `[data-testid="who"]` gaining text — and, if it + * does not come, say what the page was doing instead of merely that it did not. + * + * One function for both profiles because the failure is the same failure and deserves the + * same report; only the bound differs, since a cold profile waits on provisioning that + * round-trips the broker once per repo and a warm one does not. + */ +async function firstRender(what: string, bound: number, label: string, page: Page, frame: Frame): Promise { + try { + await measured(what, bound, (ms) => + frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: ms }), + ); + } catch (e) { + await reportStalledRender(label, page, frame); + throw e; + } +} + +/** The first render on a COLD profile — see {@link COLD_FIRST_RENDER_MS} for why it is wider. */ +function coldFirstRender(label: string, page: Page, frame: Frame): Promise { + return firstRender("a cold profile's first render", COLD_FIRST_RENDER_MS, label, page, frame); +} + +/** + * Sign an actor in, and — if that fails — leave nothing of the attempt running. + * + * ── Why the failure path closes the page ───────────────────────────────────── + * `within` abandons a wait; it cannot CANCEL it, and nothing can cancel a browser round-trip + * (`deadline.ts` says so). So a sign-in that outlives its bound leaves a real page still + * walking the broker's login: clicking, filling, navigating — an actor nobody is accounting + * for, driving the same profile the next journey is about to drive. Closing that page is the + * only cancellation available, and it is what stops one journey's failure from becoming the + * next one's mystery. + * + * The step trail is the other half. A sign-in is four bounded waits, and when the enclosure + * was the first to expire the report named none of them; the trail says how far it got even + * when the thing that failed is the enclosure itself. + */ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise { - 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`). Here it is also how the suite - // signs an actor in without typing. - // - // No barrier is met on this path, and the reason is the FRAME, not the identifier: - // `setupBrokerPage` goes straight to the broker's redirect, so the application only - // ever loads inside the iframe — where the round-trip is already behind it. The two - // journeys that load the application's own address top-level do meet the barrier, and - // must: that is the side a person actually arrives on. - 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 }; - }); + const opened: { page: Page | null } = { page: null }; + const startedAt = Date.now(); + const trail: string[] = []; + const at = (what: string): void => { + trail.push(`+${((Date.now() - startedAt) / 1000).toFixed(1)}s ${what}`); + }; + try { + return await measured("an actor's sign-in", SIGN_IN_MS, (bound) => + within(`${id} to sign in`, bound, async () => { + // `measured`, not `step`: `newPage` carries `NEW_PAGE_MS` itself, so this only times it. + const page = await measured("a page for an actor", NEW_PAGE_MS, () => newPage(id, ctx)); + opened.page = page; + at("page opened"); + 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`). Here it is also how the suite + // signs an actor in without typing. + // + // No barrier is met on this path, and the reason is the FRAME, not the identifier: + // `setupBrokerPage` goes straight to the broker's redirect, so the application only + // ever loads inside the iframe — where the round-trip is already behind it. The two + // journeys that load the application's own address top-level do meet the barrier, and + // must: that is the side a person actually arrives on. + const frame = await measured("an actor's broker round-trip", BROKER_ROUND_TRIP_MS, () => + setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`), + ); + at("back inside the broker iframe"); + await firstRender("an actor's first render", FIRST_RENDER_MS, `${id}'s sign-in`, page, frame); + at("the application rendered"); + return { id, frame, page }; + }), + ); + } catch (e) { + console.error( + ` [${id} sign-in] gave up after ${((Date.now() - startedAt) / 1000).toFixed(1)}s. How far it got:\n` + + (trail.length === 0 ? " (nothing happened)" : trail.map((s) => ` ${s}`).join("\n")), + ); + if (opened.page !== null) { + await closeQuietly(`${id}'s abandoned sign-in page`, () => opened.page!.close()); + } + throw e; + } } // ── the acts, expressed as the application expresses them ─────────────────── @@ -224,10 +729,12 @@ async function showScope(a: Actor, scope: string, settle: string): Promise // No `.catch` swallowing the timeout either: a list that never settles is a failure to // see, not a degradation to absorb — swallowing it reinstated the very bug this wait // was added to fix. - await a.frame - .locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`) - .first() - .waitFor({ timeout: 60000 }); + await measured("a note list settling after a shelf change", LIST_SETTLE_MS, (ms) => + a.frame + .locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`) + .first() + .waitFor({ timeout: ms }), + ); } async function writeNote(a: Actor, scope: string, title: string, body: string): Promise { @@ -237,7 +744,9 @@ async function writeNote(a: Actor, scope: string, title: string, body: string): // and the shelf we are switching to may legitimately be empty or hold anything. await a.frame.locator('[data-testid="scope"]').selectOption(scope); await a.frame.locator('[data-testid="write"]').click(); - await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 }); + await measured("a note written and read back", WRITE_NOTE_MS, (ms) => + a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: ms }), + ); } /** The reference the application SHOWS for a note — what a human would copy out. */ @@ -255,43 +764,68 @@ async function openReceivedNote(a: Actor, reference: string): Promise { await a.frame.locator('[data-testid="reference"]').fill(reference); await a.frame.locator('[data-testid="open-reference"]').click(); const out = a.frame.locator('[data-testid="shared"]'); - await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); + await measured("the answer to an opened reference", ANSWER_MS, (ms) => + out.filter({ hasText: /\S/ }).waitFor({ timeout: ms }), + ).catch(() => {}); return (await out.textContent())?.trim() ?? ""; } async function shareNoteWith(a: Actor, title: string, withId: string): Promise { await a.frame.locator('[data-testid="share-with"]').fill(withId); await a.frame.locator(`li:has-text("${title}") button.share`).click(); - await a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 }); + await measured("a capability deposited for another identity", DEPOSIT_MS, (ms) => + a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: ms }), + ); } async function openForMessages(a: Actor, title: string): Promise { await a.frame.locator(`li:has-text("${title}") button.open`).click(); - await a.frame - .locator('[data-testid="share-result"]') - .filter({ hasText: "ouverte aux messages" }) - .waitFor({ timeout: 60000 }); + await measured("a note opened to messages", DEPOSIT_MS, (ms) => + a.frame + .locator('[data-testid="share-result"]') + .filter({ hasText: "ouverte aux messages" }) + .waitFor({ timeout: ms }), + ); } async function leaveMessage(a: Actor, reference: string, text: string): Promise { await a.frame.locator('[data-testid="on-note"]').fill(reference); await a.frame.locator('[data-testid="message"]').fill(text); await a.frame.locator('[data-testid="leave"]').click(); - await a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 }); + await measured("a message left on a note", DEPOSIT_MS, (ms) => + a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: ms }), + ); } async function readMessages(a: Actor, title: string): Promise { await a.frame.locator(`li:has-text("${title}") button.msgs`).click(); const out = a.frame.locator('[data-testid="messages"]'); - await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); + await measured("the messages on a note", ANSWER_MS, (ms) => + out.filter({ hasText: /\S/ }).waitFor({ timeout: ms }), + ).catch(() => {}); return (await out.textContent())?.trim() ?? ""; } -/** Reload the page: what a user does, and what makes a durable fact distinguishable - * from one that only lived in this tab's memory. */ +/** + * Reload the page: what a user does, and what makes a durable fact distinguishable from one + * that only lived in this tab's memory. + * + * ── Why the new page is signed in BEFORE the old one is closed ─────────────── + * Because the other order is what let one failure become three. Closing first and failing + * second leaves the actor holding a page that no longer exists, and every later journey that + * touches him then fails on `Target page, context or browser has been closed` — naming its + * own action instead of this reopen. VERIFIED 2026-08-16 (run 1): Bob's reopen inside journey + * 2 hit its bound, and journeys 3 and 4 failed on his corpse. + * + * This way a failed reopen changes NOTHING: the actor keeps the session he already had, the + * journey that attempted it fails alone, and the journeys after it run on a live actor. The + * cost is a third broker page open for the couple of seconds the sign-in takes, which is a + * state the profile is already in — the two actors' pages coexist for the whole run. + */ async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise { + const next = await signIn(ctx, appUrl, a.id); await closeQuietly(`${a.id}'s previous page`, () => a.page.close()); - return signIn(ctx, appUrl, a.id); + return next; } // ── the journeys ──────────────────────────────────────────────────────────── @@ -300,7 +834,10 @@ async function main(): Promise { // 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); + // With `finish`, so a run that trips the wall clock still prints a summary with the same + // check total as any other — the watchdog exists to replace a silent kill with a report, + // and exiting without one would just be a slower silent kill. + armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS, () => finish("the suite exceeded its wall clock")); console.log("[e2e/app] building the example application..."); buildApp(); console.log("[e2e/app] ensuring the batch wallet..."); @@ -317,8 +854,6 @@ async function main(): Promise { let ctx: BrowserContext | null = null; let closeServer: (() => void) | null = null; - const startedAt = Date.now(); - try { // 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 @@ -346,79 +881,115 @@ async function main(): Promise { 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); - check("Bob signs in, in his own space", true, `who=${BOB}`); + // The actors sign in inside a JOURNEY, not at the suite's top level. Up here a failed + // sign-in threw past every journey into `main`'s own catch, which prints "fatal" and + // exits — no summary, no checks, nothing a previous run could be compared against. As a + // journey it is a failure like any other, and the journeys that need an actor declare it + // (`needs`) instead of discovering it as a timeout on an innocent selector. + let alice: Actor | null = null; + let bob: Actor | null = null; + const aliceIsUp: Prerequisite = () => actorTrouble(ALICE, alice); + const bobIsUp: Prerequisite = () => actorTrouble(BOB, bob); + + await journey({ + name: "Alice and Bob each sign in, in their own space", + run: async () => { + alice = await signIn(ctx!, url, ALICE); + check("Alice signs in and the application knows who she is", true, `who=${ALICE}`); + bob = await signIn(ctx!, url, BOB); + check("Bob signs in, in his own space", true, `who=${BOB}`); + }, + }); // 1. A public note travels on its reference alone — the property the public-store // emulation exists for. Nothing but the reference crosses, and no key does. let publicRef = ""; - await journey("Bob reads Alice's public note from its reference alone", async () => { - await writeNote(alice, "public", "Courses", "pain, café"); - publicRef = await referenceOnScreen(alice, "Courses"); - check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef); - // The one value that crosses, and it crosses the way it would in life: copied off - // one screen, pasted into another. It carries no key. - const read = await openReceivedNote(bob, publicRef); - check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read); - check("the reference carried no key", !publicRef.includes(":r:"), publicRef); + await journey({ + name: "Bob reads Alice's public note from its reference alone", + needs: [aliceIsUp, bobIsUp], + run: async () => { + const a = await must(ALICE, alice); + await writeNote(a, "public", "Courses", "pain, café"); + publicRef = await referenceOnScreen(a, "Courses"); + check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef); + // The one value that crosses, and it crosses the way it would in life: copied off + // one screen, pasted into another. It carries no key. + const read = await openReceivedNote((await must(BOB, bob)), publicRef); + check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read); + check("the reference carried no key", !publicRef.includes(":r:"), publicRef); + }, }); // 2. A protected note does NOT travel on its reference — until its owner shares it. // Same gesture on Bob's side, opposite outcome, decided by where the note sits. - let secretRef = ""; - await journey("Alice's protected note stays shut until she gives Bob the key", async () => { - await writeNote(alice, "protected", "Anniversaire", "surprise pour Bob"); - secretRef = await referenceOnScreen(alice, "Anniversaire"); - const before = await openReceivedNote(bob, secretRef); - check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)"); + await journey({ + name: "Alice's protected note stays shut until she gives Bob the key", + needs: [aliceIsUp, bobIsUp], + run: async () => { + const a = await must(ALICE, alice); + await writeNote(a, "protected", "Anniversaire", "surprise pour Bob"); + const secretRef = await referenceOnScreen(a, "Anniversaire"); + const before = await openReceivedNote((await must(BOB, bob)), secretRef); + check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)"); - await shareNoteWith(alice, "Anniversaire", BOB); - // Bob reopens the application: connecting is what applies what was deposited for - // him. He calls nothing — there is no "receive" in this model. - const bob2 = await reopen(ctx!, url, bob); - const after = await openReceivedNote(bob2, secretRef); - check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after); - bob.frame = bob2.frame; - bob.page = bob2.page; + await shareNoteWith(a, "Anniversaire", BOB); + // Bob reopens the application: connecting is what applies what was deposited for + // him. He calls nothing — there is no "receive" in this model. The assignment is the + // whole update: `reopen` hands back a new actor and only closes the old page once the + // new one is up, so a failure here leaves `bob` exactly as he was. + bob = await reopen(ctx!, url, await must(BOB, bob)); + const after = await openReceivedNote(bob, secretRef); + check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after); + }, }); // 3. A note opened for messages: anyone deposits, only its owner reads. Bob addresses // the NOTE — he never names an inbox, and no application should have to. - await journey("Bob leaves a message on Alice's note, and only Alice reads it", async () => { - await showScope(alice, "public", "Courses"); // her public shelf - await openForMessages(alice, "Courses"); - // Bob has to REOPEN so the address published on the note is visible to his session. - const bob2 = await reopen(ctx!, url, bob); - await leaveMessage(bob2, publicRef, "j'apporte le café"); - const mine = await readMessages(alice, "Courses"); - check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine); - bob.frame = bob2.frame; - bob.page = bob2.page; + await journey({ + name: "Bob leaves a message on Alice's note, and only Alice reads it", + // The reference is a real prerequisite, not a formality: journey 1 produces it, and if + // it did not, `leaveMessage` would post to the empty string and fail on a wait that + // names the deposit rather than the journey that never made the note. + needs: [aliceIsUp, bobIsUp, () => (publicRef === "" ? "Alice's public note was never written" : null)], + run: async () => { + const a = await must(ALICE, alice); + await showScope(a, "public", "Courses"); // her public shelf + await openForMessages(a, "Courses"); + // Bob has to REOPEN so the address published on the note is visible to his session. + bob = await reopen(ctx!, url, await must(BOB, bob)); + await leaveMessage(bob, publicRef, "j'apporte le café"); + const mine = await readMessages(a, "Courses"); + check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine); + }, }); // 4. Each actor lists their OWN notes and nothing else — the boundary, seen from // the only place that matters: what the screen shows. - await journey("each actor's list holds their own notes, and no one else's", async () => { - // POSITIVE CONTROL. Bob writes a public note of his own first — without it his list - // is empty whatever the boundary does, and "it does not contain Alice's note" is - // true for the wrong reason. The assertion has to be able to fail. - await writeNote(bob, "public", "Vélo", "réviser les freins"); - await showScope(bob, "public", "Vélo"); - const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? ""; + await journey({ + name: "each actor's list holds their own notes, and no one else's", + needs: [aliceIsUp, bobIsUp], + run: async () => { + const a = await must(ALICE, alice); + const b = await must(BOB, bob); + // POSITIVE CONTROL. Bob writes a public note of his own first — without it his list + // is empty whatever the boundary does, and "it does not contain Alice's note" is + // true for the wrong reason. The assertion has to be able to fail. + await writeNote(b, "public", "Vélo", "réviser les freins"); + await showScope(b, "public", "Vélo"); + const bobList = (await b.frame.locator('[data-testid="notes"]').textContent()) ?? ""; - // Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see - // it" is read off a stale snapshot and holds whatever the boundary does. Writing a - // note is the synchronisation point the application offers: `writeNote` awaits the - // new entry appearing, so what follows is a render that post-dates Bob's. - await writeNote(alice, "public", "Timbres", "en acheter un carnet"); - const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? ""; + // Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see + // it" is read off a stale snapshot and holds whatever the boundary does. Writing a + // note is the synchronisation point the application offers: `writeNote` awaits the + // new entry appearing, so what follows is a render that post-dates Bob's. + await writeNote(a, "public", "Timbres", "en acheter un carnet"); + const aliceList = (await a.frame.locator('[data-testid="notes"]').textContent()) ?? ""; - check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60)); - check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60)); - 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)); + check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60)); + check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60)); + 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 @@ -433,36 +1004,45 @@ async function main(): Promise { // 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 () => { + await journey({ + name: "a first-time user, holding nothing, gets in through the barrier", + run: async () => { const newcomer = `newcomer-${t}`; const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw"); const fresh = await launchCleanProfileContext(); + // `page` exists for the `finally`; `visitor` is the same page as a non-null local, so + // the body reads without an assertion at every use. 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) => { + const visitor = await fresh.ctx.newPage(); + page = visitor; + visitor.on("pageerror", (e) => console.error("[newcomer pageerror]", e.message)); + visitor.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 }); + await visitor.goto(url, { waitUntil: "domcontentloaded" }); + const gate = visitor.locator('[data-ng-eventually="access-gate"]'); + const identityField = visitor.locator('[data-testid="ng-identity-input"]'); + await measured("the barrier painting on a cold profile", BARRIER_MS, (ms) => + identityField.waitFor({ state: "visible", timeout: ms }), + ); // 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(), + visitor.url().startsWith(url), + visitor.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 }), + measured("the wallet file arriving as a download", BARRIER_TAB_MS, (ms) => + visitor.waitForEvent("download", { timeout: ms }), + ), gate.locator("a[download]").click(), ]); const failure = await download.failure(); @@ -484,27 +1064,37 @@ async function main(): Promise { // 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 }), + measured("the wallet application opening in its own tab", BARRIER_TAB_MS, (ms) => + fresh.ctx.waitForEvent("page", { timeout: ms }), + ), gate.locator('a[target="_blank"]').click(), ]); - await importWalletViaFile(walletPage, downloaded, password); - await walletPage.close().catch(() => {}); + await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => + importWalletViaFile(walletPage, downloaded, password), + ); + await closeQuietly("the wallet application's tab", () => + within("the wallet application's tab to close", WALLET_TAB_CLOSE_MS, () => walletPage.close()), + ); // 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(); + await visitor.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); + await measured("the application handing the page to the broker", HANDOVER_MS, (ms) => + visitor.waitForURL(/nextgraph\./, { timeout: ms }), + ).catch(() => {}); + check("the application hands the page to the broker itself", /nextgraph\./.test(visitor.url()), visitor.url()); + const frame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => + completeBrokerLogin(visitor, 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)}…`, + frame !== visitor.mainFrame() && /nextgraph\./.test(visitor.mainFrame().url()), + `top=${visitor.mainFrame().url().slice(0, 40)}… app=${frame.url().slice(0, 40)}…`, ); // THE assertion this journey exists for. The identifier crosses the round-trip in @@ -512,13 +1102,13 @@ async function main(): Promise { // 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 arrived: Actor = { id: newcomer, frame, page: visitor }; + // A wider 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. A bound, + // not a sleep: it fails if the application never comes up, and says what it was + // showing when it did not. + await coldFirstRender("newcomer", visitor, frame); 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( @@ -539,6 +1129,7 @@ async function main(): Promise { await closeContext("clean-profile", fresh.ctx); try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } } + }, }); // 6. The visit AFTER the first one, on the application's own address. The barrier used @@ -557,7 +1148,9 @@ async function main(): Promise { // device that starts with nothing is the only one on which the wallet can be // OBTAINED rather than found already there. It also keeps this journey off the // actors' profile, which no journey should be adding broker pages to. - await journey("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => { + await journey({ + name: "a returning visitor meets the barrier again, prefilled, and keeps their space", + run: async () => { const returning = `returning-${t}`; const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw"); const fresh = await launchCleanProfileContext(); @@ -579,49 +1172,69 @@ async function main(): Promise { if (m.type() === "error") console.error(`[${label} console]`, m.text()); }); await p.goto(url, { waitUntil: "domcontentloaded" }); - await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 }); + await measured("the barrier painting on a cold profile", BARRIER_MS, (ms) => + p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: ms }), + ); return p; }; try { // The FIRST visit — how the identifier and the wallet come to exist at all on this // device. Both are obtained here, neither is handed over by the test. - first = await arriveAtTheBarrier("returning-first-visit"); + // Same shape as the newcomer's journey: `first`/`again` exist for the `finally`, + // `firstVisit`/`returnVisit` are the same pages as non-null locals. + const firstVisit = await arriveAtTheBarrier("returning-first-visit"); + first = firstVisit; at("first visit: the barrier is up"); - const gate = first.locator('[data-ng-eventually="access-gate"]'); + const gate = firstVisit.locator('[data-ng-eventually="access-gate"]'); const [download] = await Promise.all([ - first.waitForEvent("download", { timeout: 30000 }), + measured("the wallet file arriving as a download", BARRIER_TAB_MS, (ms) => + firstVisit.waitForEvent("download", { timeout: ms }), + ), gate.locator("a[download]").click(), ]); await download.saveAs(downloaded); at("first visit: the wallet is downloaded"); const password = ((await gate.locator("code").first().textContent()) ?? "").trim(); + at("first visit: the password is read off the barrier"); const [walletPage] = await Promise.all([ - fresh.ctx.waitForEvent("page", { timeout: 30000 }), + measured("the wallet application opening in its own tab", BARRIER_TAB_MS, (ms) => + fresh.ctx.waitForEvent("page", { timeout: ms }), + ), gate.locator('a[target="_blank"]').click(), ]); - await importWalletViaFile(walletPage, downloaded, password); - await walletPage.close().catch(() => {}); + at("first visit: the wallet application is open in its own tab"); + await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => + importWalletViaFile(walletPage, downloaded, password), + ); at("first visit: the wallet is imported on this device"); - await first.locator('[data-testid="ng-identity-input"]').fill(returning); - await first.locator('[data-testid="ng-identity-enter"]').click(); + await closeQuietly("the wallet application's tab", () => + within("the wallet application's tab to close", WALLET_TAB_CLOSE_MS, () => walletPage.close()), + ); + at("first visit: the wallet application's tab is closed"); + await firstVisit.locator('[data-testid="ng-identity-input"]').fill(returning); + await firstVisit.locator('[data-testid="ng-identity-enter"]').click(); // The APPLICATION navigates, and it has to have DONE so before the broker login is // driven: until then the top-level frame is still the application's own, and // `completeBrokerLogin` would hand back that frame — which then navigates away, so // everything waited for on it waits forever. Cost two runs to see. - await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); + await measured("the application handing the page to the broker", HANDOVER_MS, (ms) => + firstVisit.waitForURL(/nextgraph\./, { timeout: ms }), + ).catch(() => {}); at("first visit: handed over to the broker"); - const firstFrame = await completeBrokerLogin(first, url); - await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); + const firstFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => + completeBrokerLogin(firstVisit, url), + ); + await coldFirstRender("returning-first-visit", firstVisit, firstFrame); // A note, so the second visit can be shown to land in the SAME space rather than // merely displaying the same name. at("first visit: the application is up"); - await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite"); + await writeNote({ id: returning, frame: firstFrame, page: firstVisit }, "protected", "Carnet", "de la première visite"); at("first visit: a note is written"); // Closed here, not merely at the end: the second visit has to be a fresh page that // finds the identifier where the FIRST one left it, not a tab still holding it — // and one broker page at a time in this profile. - await closeQuietly("the first visit's page", () => first!.close()); + await closeQuietly("the first visit's page", () => firstVisit.close()); first = null; // The SECOND visit — the same address a bookmark would open, nothing appended. The @@ -629,18 +1242,19 @@ async function main(): Promise { // steps are offered again and this person walks past them to the field. Whether a // wallet was imported lives in another origin's storage and is unreadable, so the // alternative would be asking them — the question this design refuses. - again = await arriveAtTheBarrier("returning-second-visit"); + const returnVisit = await arriveAtTheBarrier("returning-second-visit"); + again = returnVisit; at("return visit: the barrier is up again"); check( "the barrier still hands out the wallet without asking whether they have it", - (await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1, + (await returnVisit.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1, ); check( "the barrier appears again, on a visit where the identifier is already known", - again.url().startsWith(url), - again.url(), + returnVisit.url().startsWith(url), + returnVisit.url(), ); - const prefilled = await again.locator('[data-testid="ng-identity-input"]').inputValue(); + const prefilled = await returnVisit.locator('[data-testid="ng-identity-input"]').inputValue(); check( "and it arrives prefilled — one click, nothing to retype", prefilled === returning, @@ -649,29 +1263,36 @@ async function main(): Promise { // Confirmed, not retyped: what settles the identity here is the value the barrier // itself put in the field. - await again.locator('[data-testid="ng-identity-enter"]').click(); - await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); + await returnVisit.locator('[data-testid="ng-identity-enter"]').click(); + await measured("the application handing the page to the broker", HANDOVER_MS, (ms) => + returnVisit.waitForURL(/nextgraph\./, { timeout: ms }), + ).catch(() => {}); check( "confirming the prefilled field is what hands the page over", - /nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`), - again.url(), + /nextgraph\./.test(returnVisit.url()) && returnVisit.url().includes(`ng-id%3D${returning}`), + returnVisit.url(), + ); + const backFrame = await measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => + completeBrokerLogin(returnVisit, url), ); - const backFrame = await completeBrokerLogin(again, url); at("return visit: back inside the broker iframe"); - await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); + await coldFirstRender("returning-second-visit", returnVisit, backFrame); + at("return visit: the application is up"); const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim(); check("the round-trip brings them back as the same identity", who.includes(returning), who); // The identity is one space, not two — the failure that skipping the barrier used // to hide was precisely a SECOND virtual space that looked like a working // application. A note written before the round-trip is what tells them apart. - const arrived: Actor = { id: returning, frame: backFrame, page: again }; + const arrived: Actor = { id: returning, frame: backFrame, page: returnVisit }; await showScope(arrived, "protected", "Carnet"); // `showScope` settles on an EMPTY list too, and a page this fresh can render one // before its repos have synchronised — so the marker gets its own bounded wait. // Not swallowed: if it never arrives, the check below reads the list and fails on // what is actually there. - await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {}); + await measured("a note from an earlier visit reappearing", WRITE_NOTE_MS, (ms) => + backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: ms }), + ).catch(() => {}); const list = (await backFrame.locator('[data-testid="notes"]').textContent()) ?? ""; check( "and into the same space — the note from the first visit is still theirs", @@ -684,6 +1305,7 @@ async function main(): Promise { await closeContext("returning-visitor", fresh.ctx); try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } } + }, }); } finally { // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already @@ -693,18 +1315,15 @@ async function main(): Promise { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } } - const failed = results.filter((r) => !r.ok).length; - const minutes = ((Date.now() - startedAt) / 60000).toFixed(1); - console.log( - `\n══ Application e2e summary: ${results.length - failed} passed, ${failed} failed, ` + - `${results.length} total — ${minutes} min ══`, - ); - process.exit(failed === 0 ? 0 : 1); + finish(null); } 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. + // Anything the journeys did not catch — a refused run lock, a wallet export that hung, a + // browser lost during setup. Reported through the SAME summary as everything else rather + // than as a bare `fatal:`, because a run that prints no summary is a run whose numbers + // cannot be compared with any other. VERIFIED 2026-08-16: the export hung and this path + // printed a stack and left, so the batch reported zero checks out of zero. console.error("[e2e/app] fatal:", e); - process.exit(1); + finish(firstLine(e)); });