Files
ng-eventually/packages/polyfill/e2e/notebook.ts
T
Sylvain Duchesne 98ee511d3a test(e2e): le parcours qui manquait — Alice revient, et tout est encore à elle
Deux défauts ont été livrés et rapportés par une application, sans que la suite
applicative puisse les voir. Le trou était précis : aucun parcours ne faisait
revenir un PROPRIÉTAIRE après qu'il a ouvert son document aux messages. Le
parcours 3 fait ouvrir Alice et revenir Bob ; Alice, elle, ne se reconnecte
jamais. Il s'arrêtait une reconnexion trop tôt.

Alice écrit deux notes publiques, en ouvre une aux messages, Bob y dépose en la
nommant, puis Alice revient. Elle doit être reconnue, lire la note qu'elle a
faite en ne tenant que sa référence, POUVOIR ENCORE Y ÉCRIRE, et trouver le
message laissé en son absence.

L'écriture compte autant que la lecture : un document public survit à une
relecture après rechargement, sa clé étant retrouvée dans le store, et n'échoue
qu'à l'écriture. Un parcours qui se contenterait de relire aurait manqué la
moitié.

Et il a été vérifié contre le code d'AVANT le correctif, dans un worktree
jetable : les quatre vérifications échouent, sur
« docs.sparqlQuery: refused — the connected user does not hold this document's
cap ». Ce refus apparaît une fois dans chaque journal d'avant et zéro fois dans
les trois d'après. Un parcours qui passe des deux côtés ne prouve rien — c'est
exactement comme ça que ce trou avait survécu.

La reconnexion est vraie : nouvelle page, réalisme JS neuf, donc tous les caches
de module disparaissent pendant que le portefeuille reste intact. Rien n'est
pré-injecté — la référence qu'Alice colle, elle l'a lue sur son propre écran.

Total de vérifications : 34 → 39.
2026-08-16 19:04:25 +02:00

1285 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.32.8s for an actor and 1.61.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.97.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.97.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.912s (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.81.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.21.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.00.1s),
* and the wallet application opening in its second tab (measured 0.20.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.82.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; eight 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: "Alice comes back after opening her note for messages, and it is all still hers",
checks: [
"coming back is granted, and the application knows her again",
"she goes straight to the note she made and reads it, holding only its reference",
"she can still WRITE to a note she made before she came back",
"the message left while she was away is waiting for her",
],
},
{
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<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
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 = '<script type="module" src="/app.js"></script>';
if (!page.includes(moduleTag)) {
throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`);
}
const html = page.replace(
moduleTag,
`<script>globalThis.__NOTEBOOK_WALLET_PASSWORD__ = ${JSON.stringify(walletPassword)};</script>\n ${moduleTag}`,
);
return serveOnEphemeralPort((req, res) => {
const route = (req.url ?? "/").split("?")[0];
if (route === "/app.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else 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<string | null> {
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<Actor> {
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<void> {
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<void> => {
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<void> {
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<void> {
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<Actor> {
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<void> {
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<void> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<string> {
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<Actor> {
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<void> {
// 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. The reconnection nobody made. Every journey above stops one reconnection short:
// Alice opens her note to messages and it is always somebody ELSE who reopens. So
// what an owner holds on a FRESH session over the same durable wallet was never
// looked at, and two defects shipped behind that gap — both reported by a consuming
// application, neither visible here (fixed 2026-08-16, `f6d1734`):
//
// - connecting replayed only the register of what was SHARED WITH you (the User
// branch's Links) and never the one holding the keys of the documents you MADE
// (the Store branch). A creator who came back and went straight to their own
// note held nothing for it: a public one still READ, because its store hands
// the key to whoever asks, and the WRITE was refused — which is why this
// journey does both, and why a read alone would have missed the defect;
// - and the inbox opened on a document was enumerated at connection without its
// key, so draining it failed — and a failed drain took the whole sign-in down
// with it. An inbox is not consumed by failing, so it refused again at every
// later attempt: not a delayed share, a person locked out for good.
//
// Hence the shape: an owner reaches a state only an owner reaches — a document of
// her own, open to messages, holding somebody else's deposit — and only THEN comes
// back. Nothing is seeded: the reconnection is a new page over the same wallet,
// which is what a person's reload produces, and everything she finds afterwards
// she finds through the application.
await journey({
name: "Alice comes back after opening her note for messages, and it is all still hers",
needs: [aliceIsUp, bobIsUp],
run: async () => {
const before = await must(ALICE, alice);
// TWO notes, and the second one is not a spare: `openDocumentInbox` is idempotent,
// so opening the FIRST one again after coming back would return early and write
// nothing. A note she has not yet opened is what makes "she can still write to a
// document she made" an actual write rather than a lookup.
await writeNote(before, "public", "Boîte à idées", "laissez vos suggestions");
const ideasRef = await referenceOnScreen(before, "Boîte à idées");
await writeNote(before, "public", "Recettes", "tarte aux pommes");
await openForMessages(before, "Boîte à idées");
// Bob reopens so the address published on the note is visible to his session, then
// deposits by naming the NOTE — the published call, no inbox in sight. The
// reference is the one value that crosses, off Alice's screen, as in journey 1.
bob = await reopen(ctx!, url, await must(BOB, bob));
await leaveMessage(bob, ideasRef, "et si on ajoutait un index ?");
// THE reconnection this journey exists for. A new page over the same durable
// wallet: every module-level cache the library holds is gone, and what she finds
// is what connecting restored. `reopen` signs the new page in before closing the
// old one, so a refusal here leaves Alice exactly as she was.
alice = await reopen(ctx!, url, before);
const back = await must(ALICE, alice);
// Granted at all — which is the whole of the second defect. Pre-fix her undrainable
// queue rejected `ensureIdentity()`, the application never rendered, and this
// journey died on the sign-in with none of its checks reported.
const who = ((await back.frame.locator('[data-testid="who"]').textContent()) ?? "").trim();
check("coming back is granted, and the application knows her again", who.includes(ALICE), who);
// Straight to her note, by its reference — a bookmark, a deep link — and
// DELIBERATELY before anything lists her public shelf: listing a scope re-reads its
// Store branch and puts those keys back, so a read taken after one proves only that
// the listing healed it.
const mine = await openReceivedNote(back, ideasRef);
check(
"she goes straight to the note she made and reads it, holding only its reference",
mine.includes("Boîte à idées") && mine.includes("laissez vos suggestions"),
mine,
);
await showScope(back, "public", "Recettes");
// `showScope` settles on an EMPTY list too, and a page this fresh can render one
// before its repos have synchronised — so the note gets its own bounded wait. Not
// swallowed here, unlike journey 7: what follows is a CLICK on that note, and a
// click on an absent one would report Playwright's own default rather than the
// named step that actually waited.
await measured("a note from an earlier visit reappearing", WRITE_NOTE_MS, (ms) =>
back.frame.locator('li:has-text("Recettes")').waitFor({ timeout: ms }),
);
// The WRITE — a statement anchored on a document she created in the session before
// this one, which is the act her own key is needed for and the one a public store
// does not cover.
await openForMessages(back, "Recettes");
// Read off the screen rather than asserted as a literal `true`: what this journey is
// entitled to claim is what the application SAYS happened, and a detail a reader can
// compare against a failing run is worth more than a constant.
const said = ((await back.frame.locator('[data-testid="share-result"]').textContent()) ?? "").trim();
check("she can still WRITE to a note she made before she came back", said.includes("ouverte aux messages"), said);
const left = await readMessages(back, "Boîte à idées");
check(
"the message left while she was away is waiting for her",
left.includes("et si on ajoutait un index ?"),
left,
);
},
});
// 5. 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));
},
});
// 6. 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();
}
},
});
// 7. 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<Page> => {
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));
});