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));
});