/** * The APPLICATIVE e2e suite — the same broker, driven through the example application. * * ── Why this exists beside `run.ts` ─────────────────────────────────────── * `run.ts` drives a bag of methods on `window.__sdk`. That proves the functions RUN; it * cannot prove an application can be written with them, and the difference has already * cost a shipped defect: a document's inbox was green there and unusable in practice, * because the harness handed an address across an identity boundary through a JS * variable — a channel no application has. * * This suite has no such channel. It drives `examples/notebook` through the DOM, one * browser page per identity, and the only things that cross between them are the ones * that cross in reality: a note's REFERENCE (copied from Alice's screen, as a human * would copy it into a message) and an identifier typed into a field. Everything else * each actor must OBTAIN through the application. * * The division of labour with `run.ts`: platform contracts, primitive characterisation * and cold-start regressions stay there — they need privileged access, fresh profiles * and raw SPARQL, and they are about the broker, not about an application. What lives * here is the journeys, and they read as journeys. * * ── Why a bare reference is allowed to cross ────────────────────────────── * Because the model says it circulates: it names a note and grants nothing, and if the * note sits in a public store its cap is served to whoever asks * (`emulated-verifier/public-store.ts`). A test that had to pass a KEY between actors * would be describing something no application can do — that is the line, and it is the * reason the application displays each note's reference: what no screen shows, no user * can circulate. */ import { type BrowserContext, type Frame, type Page } from "playwright"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { BROKER_ROUND_TRIP_MS, NEW_PAGE_MS, armSuiteDeadline, browserTrouble, closeContext, closeQuietly, completeBrokerLogin, declareSuite, emptyProfileContext, enclosingBound, exportWalletBytes, firstLine, frameTrouble, importWalletFile, launchWatchedContext, measured, newPage, serveOnEphemeralPort, setupBrokerPage, within, type JourneyDeclaration, type Prerequisite, type RunProfile, } from "ng-e2e-helpers"; import { WALLET, mintBatchWallet } from "./harness-page"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook"); const BUNDLE_OUT = path.resolve(__dirname, ".dist", "notebook.js"); /** Where the application configures its shared wallet (`examples/notebook/app.ts`). */ const WALLET_PATH = "/shared-wallet.ngw"; // ── bounds ────────────────────────────────────────────────────────────────── /** * ── 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 (`ng-e2e-helpers`), 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 `ng-e2e-helpers`, 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 the bounds were 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 = enclosingBound([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 ───────────────────────────────────────────────── /** * 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 SUITE: readonly JourneyDeclaration[] = [ { 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", ], }, ]; // ── reporting ─────────────────────────────────────────────────────────────── /** * The actors' browser, once it exists. * * Module level so a journey's failure can ask whether the BROWSER stopped answering before * blaming the operation it died on — the recognition lives in `ng-e2e-helpers` * (`known-failures.ts`), and this is the only thing it needs from here. */ let actorsBrowser: BrowserContext | null = null; const { check, journey, finish } = declareSuite({ label: "Application e2e", journeys: SUITE, journeyBound: JOURNEY_MS, diagnose: async () => (actorsBrowser === null ? null : browserTrouble("actors", actorsBrowser)), }); /** * 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 ──────────── function buildApp(): void { fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true }); execSync(`bun build ${path.join(APP_DIR, "app.ts")} --outfile ${BUNDLE_OUT} --bundle --format=esm`, { stdio: "pipe", cwd: APP_DIR, }); } /** * Serve the application the way a deployment would — including the two things the access * gate hands a first-time user, which nothing served before. * * The **wallet file**: `app.ts` configures `fileUrl: "/shared-wallet.ngw"`, and no such * file exists in the repository (nor should one — a wallet is never committed). So the * deployment supplies it, and here that is this server, from bytes exported at test time. * * The **password**: `app.ts` reads it from `__NOTEBOOK_WALLET_PASSWORD__`, resolved "at * its own build" as `SharedWalletConfig` requires — the library reads no environment. The * inline script below is that resolution; without it the barrier displays an empty * password and no import can succeed. * * And an unknown path now 404s instead of returning the page. That is not tidiness: the * catch-all made `/shared-wallet.ngw` answer 200 with the application's own HTML, so a * download of a file NOBODY served looked like a perfectly good download. The check that * the link resolves could not have failed. */ function serveApp(walletFile: Uint8Array, walletPassword: string): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8"); const moduleTag = ''; if (!page.includes(moduleTag)) { throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`); } const html = page.replace( moduleTag, `\n ${moduleTag}`, ); return serveOnEphemeralPort((req, res) => { const route = (req.url ?? "/").split("?")[0]; if (route === "/app.js") { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); } else if (route === WALLET_PATH) { res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Disposition": 'attachment; filename="shared-wallet.ngw"', }); res.end(walletFile); } else if (route === "/" || route === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); } else { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found"); } }); } // ── one actor = one page, signed in as one identity ───────────────────────── /** * An actor is a browser page carrying its own identity. Nothing is shared between two * actors but the broker and the application's URL — which is what makes a value crossing * from one to the other visible in this file, instead of hidden in a closure. */ interface Actor { id: string; frame: Frame; 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`; // `[data-testid="who"]` is what "this frame still holds the application" means HERE — the // states it can be in, and why an attached frame is not proof of anything, are the package's // (`known-failures.ts`). Only the marker is ours. return frameTrouble(id, a.page, a.frame, '[data-testid="who"]'); } /** * 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` (`ng-e2e-helpers`). 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 * (`ng-e2e-helpers` 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 { 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)}`, WALLET.password), ); 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 ─────────────────── /** * Show the notes of `scope` — the list is per-scope, so acting on a note means looking at * the right shelf first. * * The wait is not decoration: the application's `change` handler runs `void refresh()`, * un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS * shelf. A suite that asserts "Bob's list does not contain Alice's note" against a list * that has not re-rendered is green whether isolation holds or not — found adversarially, * 2026-08-10. */ async function showScope(a: Actor, scope: string, settle: string): Promise { await a.frame.locator('[data-testid="scope"]').selectOption(scope); // The list is rebuilt wholesale; waiting for the marker the caller expects (or for the // list to be empty) is the only signal the application offers. // // This wait is NOT a synchronisation point when the marker is ALREADY on screen — it // matches on the first poll and returns before the in-flight `refresh()` has done its // broker round-trips. A check reading the list right after is then reading the previous // render. Where a journey needs a FRESH list, it must create its own synchronisation // point (a write it awaits), not lean on this. Found adversarially, 2026-08-10. // // 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 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 { await a.frame.locator('[data-testid="title"]').fill(title); await a.frame.locator('[data-testid="body"]').fill(body); // No settle marker to wait for here: the write below is its own synchronisation point, // 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 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. */ async function referenceOnScreen(a: Actor, title: string): Promise { return (await a.frame.locator(`li:has-text("${title}") code.ref`).textContent())?.trim() ?? ""; } /** * Paste a reference and open it. The application blanks its answer before reading, so * waiting for a NON-EMPTY answer here cannot be satisfied by the previous one — a trap * this suite fell into on its first run, where a stale "readable" made an unreadable * note look readable. */ 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 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 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 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 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 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. * * ── 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 next; } // ── the journeys ──────────────────────────────────────────────────────────── async function main(): Promise { // 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(); // This run's own physical user, in a directory of its own. Nothing is shared with any other // run, so nothing has to be serialised against one: a suite belonging to a consuming // application can drive the same broker at the same time without either noticing. console.log("[e2e/app] minting this batch's wallet..."); const wallet: RunProfile = await mintBatchWallet("the applicative suite (e2e/notebook.ts)"); const t = Date.now().toString(36); const ALICE = `alice-${t}`; const BOB = `bob-${t}`; // Where the visitors' DOWNLOADS land — the `.ngw` each one pulls off the barrier and hands // to the wallet application, which takes a path and nothing else. Under a temp dir, removed // at the end: a wallet file is an identity, and one must never be committed — `*.ngw` is // gitignored besides, which is the belt to this brace. // // The wallet this suite SERVES is not here: it never becomes a file at all (see below). const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-")); let ctx: BrowserContext | null = null; let closeServer: (() => void) | null = null; 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 // iframe, so a harness page is what fetches them. // // In a context of its OWN, opened and closed before the actors'. When it shared // theirs, one run had all four existing journeys collapse together — Alice and Bob // signed in, then every `[data-testid=…]` vanished from their frames, which is what a // RELOADED iframe looks like from here. Observed once and not reproduced, so the // mechanism is a suspicion, not a finding: closing a broker page may end the session // its profile-mates are using. Cheap to avoid, so avoided — the actors inherit // nothing. Sequential contexts over the one profile dir; never two at once. // Bytes straight into the server that hands them out. They used to go through a temp // `.ngw` that was written, read straight back and deleted at the end — a file nobody // wanted, three steps to get back what the export already had in hand. console.log("[e2e/app] exporting the wallet the barrier hands out..."); const exportCtx = await launchWatchedContext("wallet-export", wallet.dir); let walletBytes: Uint8Array = new Uint8Array(0); try { walletBytes = await exportWalletBytes(exportCtx, WALLET.password); } finally { await closeContext("wallet-export", exportCtx); } ctx = await launchWatchedContext("actors", wallet.dir); actorsBrowser = ctx; const served = await serveApp(walletBytes, WALLET.password); closeServer = served.close; const url = served.url; console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletBytes.length} bytes)`); // 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({ 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. 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(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({ 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({ 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(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)); }, }); // 5. The path no journey walked: somebody who holds NOTHING. No wallet in the // profile, no identifier anywhere, and the application's own address — not the // broker redirect `signIn()` goes through, which loads the application already // inside the iframe and so never meets the barrier. Two defects shipped green // behind that shortcut: the // application handed the page to the broker BEFORE the barrier could show (a // first-time user landed on a login with no wallet and no way to get one), and the // file the barrier offers was served by nobody, so its link pointed at a 404. // // Its own browser profile, deliberately: a wallet already in the profile is the // other half of the same shortcut, and it is exactly what a first-time device // does not have. await journey({ 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 emptyProfileContext("a first-time visitor"); // `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 { 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 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", 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([ 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(); if (failure === null) await download.saveAs(downloaded); const bytes = fs.existsSync(downloaded) ? fs.statSync(downloaded).size : 0; check( "the barrier's download link serves a wallet file, not a 404", failure === null && bytes > 0, `failure=${failure ?? "none"} bytes=${bytes}`, ); // Step 2: the password, read off the screen the way a user reads it. An injected // value nobody looks at is a value that can be silently empty. const password = ((await gate.locator("code").first().textContent()) ?? "").trim(); check("the barrier shows the password for the import", password.length > 0, password); // Step 3: the wallet application, opened by the barrier's own link — a second tab, // which is what `target="_blank"` gives. Nothing proves the downloaded bytes ARE a // wallet except this import working, which is why the journey imports what it // downloaded and nothing else. const [walletPage] = await Promise.all([ 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 step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => importWalletFile(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 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 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, WALLET.password), ); check( "the application comes back inside the broker iframe", 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 // the URL and nowhere else — storage is partitioned by top-level site. Lose it and // nothing throws: the iframe reads an empty identity, provisions a SECOND virtual // space, and the user lands somewhere empty that looks like a working application. check("the identifier survived the round-trip in the URL", frame.url().includes(`ng-id=${newcomer}`), frame.url()); const arrived: Actor = { id: newcomer, frame, page: 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( "the barrier does not ask again inside the broker iframe", (await frame.locator('[data-testid="ng-identity-input"]').count()) === 0, ); // And it is a usable session, not merely a name on a screen. await writeNote(arrived, "protected", "Première note", "écrite juste après la barrière"); const list = (await frame.locator('[data-testid="notes"]').textContent()) ?? ""; check( "the newcomer writes a note and reads it back, as himself", list.includes("Première note") && list.includes("écrite juste après la barrière"), list.replace(/\s+/g, " ").slice(0, 70), ); } finally { if (page) await closeQuietly("the newcomer's page", () => page!.close()); await closeContext("clean-profile", fresh.ctx); fresh.profile.discard(); } }, }); // 6. The visit AFTER the first one, on the application's own address. The barrier used // to skip itself here — it asked only when nobody was known — and skipping is // silent: the page goes straight to the broker, and someone whose browser no longer // holds the wallet lands on a static dead end with no return path. The wallet is // handed out at the barrier and nowhere else, so the barrier has to be there. // // Nothing is seeded. The identifier this journey expects to find prefilled is the // one it typed itself, one visit earlier, at the real barrier, on a device that // started with nothing — the only way a person obtains it, and the only way this // journey may (`rule_never-shortcut-the-sign-in`). The wallet is not planted either: // it comes off the barrier's own link and through a real import, as it does above. // // Its OWN browser profile, for the same reason the newcomer's journey has one: a // 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({ 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 emptyProfileContext("a first-time visitor"); let first: Page | null = null; let again: Page | null = null; const startedAtJourney = Date.now(); /** * Progress, not assertion. This journey is the longest in the suite, and when it * overran its bound it had reported NOTHING — so there was no way to tell a slow * broker from a genuine hang, or to know which step to look at. */ const at = (what: string): void => console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`); /** Open the application's OWN address, top-level, and wait for the barrier. */ const arriveAtTheBarrier = async (label: string): Promise => { const p = await fresh.ctx.newPage(); p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message)); p.on("console", (m) => { if (m.type() === "error") console.error(`[${label} console]`, m.text()); }); await p.goto(url, { waitUntil: "domcontentloaded" }); 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. // 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 = firstVisit.locator('[data-ng-eventually="access-gate"]'); const [download] = await Promise.all([ 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([ 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(), ]); at("first visit: the wallet application is open in its own tab"); await step("a wallet imported into a cold profile", WALLET_IMPORT_MS, () => importWalletFile(walletPage, downloaded, password), ); at("first visit: the wallet is imported on this device"); 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 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 measured("a barrier passage's broker round-trip", BROKER_ROUND_TRIP_MS, () => completeBrokerLogin(firstVisit, url, WALLET.password), ); 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: 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", () => firstVisit.close()); first = null; // The SECOND visit — the same address a bookmark would open, nothing appended. The // device now holds the wallet, and the barrier still neither knows nor asks: the // 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. 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 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", returnVisit.url().startsWith(url), returnVisit.url(), ); const prefilled = await returnVisit.locator('[data-testid="ng-identity-input"]').inputValue(); check( "and it arrives prefilled — one click, nothing to retype", prefilled === returning, `field=${prefilled || "(vide)"}`, ); // Confirmed, not retyped: what settles the identity here is the value the barrier // itself put in the field. 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(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, WALLET.password), ); at("return visit: back inside the broker iframe"); 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: 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 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", list.includes("Carnet") && list.includes("de la première visite"), list.replace(/\s+/g, " ").slice(0, 70), ); } finally { if (first) await closeQuietly("the first visit's page", () => first!.close()); if (again) await closeQuietly("the return visit's page", () => again!.close()); await closeContext("returning-visitor", fresh.ctx); fresh.profile.discard(); } }, }); } finally { // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already // gone never resolves, and this `finally` is where that hang swallowed the summary. if (ctx) await closeContext("actors", ctx); closeServer?.(); try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } // This run's physical user goes with it — explicitly here, and again on the way out for // the runs that never reach a `finally`. wallet.discard(); } finish(null); } main().catch((e) => { // Anything the journeys did not catch — a wallet that could not be minted, an export that // hung, a browser lost during setup. Reported through the SAME summary as everything else // 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); finish(firstLine(e)); });