1287 lines
70 KiB
TypeScript
1287 lines
70 KiB
TypeScript
/**
|
||
* 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 { 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 BrowserContext,
|
||
type Frame,
|
||
type JourneyDeclaration,
|
||
type Page,
|
||
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; 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));
|
||
});
|