/** * 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 { PROFILE_DIR, WALLET_PASSWORD, closeContext, completeBrokerLogin, ensureWallet, exportWalletNgw, importWalletViaFile, launchCleanProfileContext, launchWalletContext, newPage, serveOnEphemeralPort, setupBrokerPage, } from "./broker"; import { armSuiteDeadline, closeQuietly, within } from "./deadline"; import { acquireRunLock } from "./run-lock"; 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 ────────────────────────────────────────────────────────────────── /** * The batch's wall clock. A healthy run measures ~1.5 min; the longest single wait in it is * the newcomer's cold first render, bounded at 3 min. Fifteen is an order of magnitude of * room and still an answer inside a coffee break — which is the point, since what this * replaces is a run that was killed by hand at 68 minutes having printed nothing. */ const SUITE_DEADLINE_MS = 15 * 60 * 1000; /** * One journey. The longest (the newcomer's) does a wallet download, an import into a cold * profile, a broker round-trip and a first render — measured under 2 min, bounded at 6. * The returning visitor's does that twice over and measures 36s, so it fits the same bound * with room to spare; a bound of its own was tried and dropped, because on a healthy host * nothing justified it and an 11-minute journey can outlast the SUITE's own deadline — * which prints no summary at all. A journey that overruns THIS is a hang or a sick host, * and both are worth hearing about rather than absorbing. */ const JOURNEY_MS = 6 * 60 * 1000; /** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */ const SIGN_IN_MS = 3 * 60 * 1000; // ── reporting ─────────────────────────────────────────────────────────────── type Check = { name: string; ok: boolean; detail?: string }; const results: Check[] = []; function check(name: string, ok: boolean, detail?: string): void { results.push({ name, ok, detail }); console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`); } /** * A journey, under its own deadline. * * The bound is what makes the catch honest: catching everything and recording a FAIL is * right for a journey that fails, but a journey that never RETURNS is caught by nothing — * and that is what three killed runs looked like from the outside. */ async function journey(name: string, fn: () => Promise, boundMs = JOURNEY_MS): Promise { console.log(`\n── ${name} ──`); try { await within(`the journey "${name}"`, boundMs, fn); } catch (e: any) { check(name, false, "threw: " + String(e?.message ?? e)); } } // ── 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: Buffer, walletPassword: string): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8"); const moduleTag = ''; if (!page.includes(moduleTag)) { throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`); } const html = page.replace( moduleTag, `\n ${moduleTag}`, ); return serveOnEphemeralPort((req, res) => { const route = (req.url ?? "/").split("?")[0]; if (route === "/app.js") { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); } else if (route === WALLET_PATH) { res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Disposition": 'attachment; filename="shared-wallet.ngw"', }); res.end(walletFile); } else if (route === "/" || route === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); } else { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found"); } }); } // ── one actor = one page, signed in as one identity ───────────────────────── /** * An actor is a browser page carrying its own identity. Nothing is shared between two * actors but the broker and the application's URL — which is what makes a value crossing * from one to the other visible in this file, instead of hidden in a closure. */ interface Actor { id: string; frame: Frame; page: Page; } async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise { return within(`${id} to sign in`, SIGN_IN_MS, async () => { const page = await newPage(id, ctx); page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message)); page.on("console", (m) => { if (m.type() === "error") console.error(`[${id} console]`, m.text()); }); // `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's // resolution order, `shared-wallet/access-gate.ts`). Here it is also how the suite // signs an actor in without typing. // // No barrier is met on this path, and the reason is the FRAME, not the identifier: // `setupBrokerPage` goes straight to the broker's redirect, so the application only // ever loads inside the iframe — where the round-trip is already behind it. The two // journeys that load the application's own address top-level do meet the barrier, and // must: that is the side a person actually arrives on. const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`); await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }); return { id, frame, page }; }); } // ── the acts, expressed as the application expresses them ─────────────────── /** * Show the notes of `scope` — the list is per-scope, so acting on a note means looking at * the right shelf first. * * The wait is not decoration: the application's `change` handler runs `void refresh()`, * un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS * shelf. A suite that asserts "Bob's list does not contain Alice's note" against a list * that has not re-rendered is green whether isolation holds or not — found adversarially, * 2026-08-10. */ async function showScope(a: Actor, scope: string, settle: string): Promise { await a.frame.locator('[data-testid="scope"]').selectOption(scope); // The list is rebuilt wholesale; waiting for the marker the caller expects (or for the // list to be empty) is the only signal the application offers. // // This wait is NOT a synchronisation point when the marker is ALREADY on screen — it // matches on the first poll and returns before the in-flight `refresh()` has done its // broker round-trips. A check reading the list right after is then reading the previous // render. Where a journey needs a FRESH list, it must create its own synchronisation // point (a write it awaits), not lean on this. Found adversarially, 2026-08-10. // // No `.catch` swallowing the timeout either: a list that never settles is a failure to // see, not a degradation to absorb — swallowing it reinstated the very bug this wait // was added to fix. await a.frame .locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`) .first() .waitFor({ timeout: 60000 }); } async function writeNote(a: Actor, scope: string, title: string, body: string): Promise { await a.frame.locator('[data-testid="title"]').fill(title); await a.frame.locator('[data-testid="body"]').fill(body); // No settle marker to wait for here: the write below is its own synchronisation point, // and the shelf we are switching to may legitimately be empty or hold anything. await a.frame.locator('[data-testid="scope"]').selectOption(scope); await a.frame.locator('[data-testid="write"]').click(); await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 }); } /** The reference the application SHOWS for a note — what a human would copy out. */ async function referenceOnScreen(a: Actor, title: string): Promise { return (await a.frame.locator(`li:has-text("${title}") code.ref`).textContent())?.trim() ?? ""; } /** * Paste a reference and open it. The application blanks its answer before reading, so * waiting for a NON-EMPTY answer here cannot be satisfied by the previous one — a trap * this suite fell into on its first run, where a stale "readable" made an unreadable * note look readable. */ async function openReceivedNote(a: Actor, reference: string): Promise { await a.frame.locator('[data-testid="reference"]').fill(reference); await a.frame.locator('[data-testid="open-reference"]').click(); const out = a.frame.locator('[data-testid="shared"]'); await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); return (await out.textContent())?.trim() ?? ""; } async function shareNoteWith(a: Actor, title: string, withId: string): Promise { await a.frame.locator('[data-testid="share-with"]').fill(withId); await a.frame.locator(`li:has-text("${title}") button.share`).click(); await a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 }); } async function openForMessages(a: Actor, title: string): Promise { await a.frame.locator(`li:has-text("${title}") button.open`).click(); await a.frame .locator('[data-testid="share-result"]') .filter({ hasText: "ouverte aux messages" }) .waitFor({ timeout: 60000 }); } async function leaveMessage(a: Actor, reference: string, text: string): Promise { await a.frame.locator('[data-testid="on-note"]').fill(reference); await a.frame.locator('[data-testid="message"]').fill(text); await a.frame.locator('[data-testid="leave"]').click(); await a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 }); } async function readMessages(a: Actor, title: string): Promise { await a.frame.locator(`li:has-text("${title}") button.msgs`).click(); const out = a.frame.locator('[data-testid="messages"]'); await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); 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. */ async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise { await closeQuietly(`${a.id}'s previous page`, () => a.page.close()); return signIn(ctx, appUrl, a.id); } // ── the journeys ──────────────────────────────────────────────────────────── async function main(): Promise { // Before anything touches the shared profile: this batch is about to DELETE it (see // `ensureWallet`), so a second run alive right now would be destroyed by this one. acquireRunLock("the applicative suite (e2e/notebook.ts)", PROFILE_DIR); armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS); console.log("[e2e/app] building the example application..."); buildApp(); console.log("[e2e/app] ensuring the batch wallet..."); await ensureWallet(); const t = Date.now().toString(36); const ALICE = `alice-${t}`; const BOB = `bob-${t}`; // The wallet the barrier hands out, and the newcomer's download of it. Both 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. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-")); const sharedWalletFile = path.join(tmpDir, "shared-wallet.ngw"); let ctx: BrowserContext | null = null; let closeServer: (() => void) | null = null; const startedAt = Date.now(); try { // THIS batch's wallet, as bytes. It has to be the very one the other actors live in — // one shared wallet hosts every identity — and its bytes exist only inside the broker // 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. console.log("[e2e/app] exporting the wallet the barrier hands out..."); const exportCtx = await launchWalletContext("wallet-export"); let walletSize = 0; try { walletSize = await exportWalletNgw(exportCtx, sharedWalletFile); } finally { await closeContext("wallet-export", exportCtx); } ctx = await launchWalletContext("actors"); const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD); closeServer = served.close; const url = served.url; console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`); const alice = await signIn(ctx, url, ALICE); check("Alice signs in and the application knows who she is", true, `who=${ALICE}`); const bob = await signIn(ctx, url, BOB); check("Bob signs in, in his own space", true, `who=${BOB}`); // 1. A public note travels on its reference alone — the property the public-store // emulation exists for. Nothing but the reference crosses, and no key does. let publicRef = ""; await journey("Bob reads Alice's public note from its reference alone", async () => { await writeNote(alice, "public", "Courses", "pain, café"); publicRef = await referenceOnScreen(alice, "Courses"); check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef); // The one value that crosses, and it crosses the way it would in life: copied off // one screen, pasted into another. It carries no key. const read = await openReceivedNote(bob, publicRef); check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read); check("the reference carried no key", !publicRef.includes(":r:"), publicRef); }); // 2. A protected note does NOT travel on its reference — until its owner shares it. // Same gesture on Bob's side, opposite outcome, decided by where the note sits. let secretRef = ""; await journey("Alice's protected note stays shut until she gives Bob the key", async () => { await writeNote(alice, "protected", "Anniversaire", "surprise pour Bob"); secretRef = await referenceOnScreen(alice, "Anniversaire"); const before = await openReceivedNote(bob, secretRef); check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)"); await shareNoteWith(alice, "Anniversaire", BOB); // Bob reopens the application: connecting is what applies what was deposited for // him. He calls nothing — there is no "receive" in this model. const bob2 = await reopen(ctx!, url, bob); const after = await openReceivedNote(bob2, secretRef); check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after); bob.frame = bob2.frame; bob.page = bob2.page; }); // 3. A note opened for messages: anyone deposits, only its owner reads. Bob addresses // the NOTE — he never names an inbox, and no application should have to. await journey("Bob leaves a message on Alice's note, and only Alice reads it", async () => { await showScope(alice, "public", "Courses"); // her public shelf await openForMessages(alice, "Courses"); // Bob has to REOPEN so the address published on the note is visible to his session. const bob2 = await reopen(ctx!, url, bob); await leaveMessage(bob2, publicRef, "j'apporte le café"); const mine = await readMessages(alice, "Courses"); check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine); bob.frame = bob2.frame; bob.page = bob2.page; }); // 4. Each actor lists their OWN notes and nothing else — the boundary, seen from // the only place that matters: what the screen shows. await journey("each actor's list holds their own notes, and no one else's", async () => { // POSITIVE CONTROL. Bob writes a public note of his own first — without it his list // is empty whatever the boundary does, and "it does not contain Alice's note" is // true for the wrong reason. The assertion has to be able to fail. await writeNote(bob, "public", "Vélo", "réviser les freins"); await showScope(bob, "public", "Vélo"); const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? ""; // Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see // it" is read off a stale snapshot and holds whatever the boundary does. Writing a // note is the synchronisation point the application offers: `writeNote` awaits the // new entry appearing, so what follows is a render that post-dates Bob's. await writeNote(alice, "public", "Timbres", "en acheter un carnet"); const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? ""; check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60)); check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60)); check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60)); check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60)); }); // 5. The path no journey walked: somebody who holds NOTHING. No wallet in the // profile, no identifier anywhere, and the application's own address — not the // broker redirect `signIn()` goes through, which loads the application already // inside the iframe and so never meets the barrier. Two defects shipped green // behind that shortcut: the // application handed the page to the broker BEFORE the barrier could show (a // first-time user landed on a login with no wallet and no way to get one), and the // file the barrier offers was served by nobody, so its link pointed at a 404. // // Its own browser profile, deliberately: a wallet already in the profile is the // other half of the same shortcut, and it is exactly what a first-time device // does not have. await journey("a first-time user, holding nothing, gets in through the barrier", async () => { const newcomer = `newcomer-${t}`; const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw"); const fresh = await launchCleanProfileContext(); let page: Page | null = null; try { page = await fresh.ctx.newPage(); page.on("pageerror", (e) => console.error("[newcomer pageerror]", e.message)); page.on("console", (m) => { if (m.type() === "error") console.error("[newcomer console]", m.text()); }); // The address a link in an email gives: the application, nothing appended. await page.goto(url, { waitUntil: "domcontentloaded" }); const gate = page.locator('[data-ng-eventually="access-gate"]'); const identityField = page.locator('[data-testid="ng-identity-input"]'); await identityField.waitFor({ state: "visible", timeout: 30000 }); // Still on the application's own page — the hand-over has NOT happened. That is // the whole of the first defect: `init()` navigated first, so everything the // application did next ran in a document that no longer existed. check( "the barrier appears, and the page has not been handed to the broker yet", page.url().startsWith(url), page.url(), ); // Step 1 of the barrier: the wallet file. Captured through the browser's own // download, which is the only thing that can say whether the link RESOLVES. const [download] = await Promise.all([ page.waitForEvent("download", { timeout: 30000 }), 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([ fresh.ctx.waitForEvent("page", { timeout: 30000 }), gate.locator('a[target="_blank"]').click(), ]); await importWalletViaFile(walletPage, downloaded, password); await walletPage.close().catch(() => {}); // Step 4: the identifier, typed. That is what settles the identity — and what // `init()` then puts in the address bar before it hands the page over. await identityField.fill(newcomer); await page.locator('[data-testid="ng-identity-enter"]').click(); // The APPLICATION navigates, not the test; then the broker loads it back inside // its iframe. `setupBrokerPage` is deliberately not used here — it would // re-navigate and throw away the URL the application had just built. await page.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); check("the application hands the page to the broker itself", /nextgraph\./.test(page.url()), page.url()); const frame = await completeBrokerLogin(page, url); check( "the application comes back inside the broker iframe", frame !== page.mainFrame() && /nextgraph\./.test(page.mainFrame().url()), `top=${page.mainFrame().url().slice(0, 40)}… app=${frame.url().slice(0, 40)}…`, ); // 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 }; // A longer bound than `signIn`'s, and for a reason the other suite already // measured: this profile is COLD — a wallet just imported, no local copy of any // repo — so the first render waits on provisioning that round-trips the broker // per repo (`run.ts` bounds the same cold-start generously for the same reason). // A bound, not a sleep: it fails if the application never comes up. await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); const 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); try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } } }); // 6. The visit AFTER the first one, on the application's own address. The barrier used // 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("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => { const returning = `returning-${t}`; const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw"); const fresh = await launchCleanProfileContext(); let first: Page | null = null; let again: Page | null = null; const startedAtJourney = Date.now(); /** * Progress, not assertion. This journey is the longest in the suite, and when it * overran its bound it had reported NOTHING — so there was no way to tell a slow * broker from a genuine hang, or to know which step to look at. */ const at = (what: string): void => console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`); /** Open the application's OWN address, top-level, and wait for the barrier. */ const arriveAtTheBarrier = async (label: string): Promise => { const p = await fresh.ctx.newPage(); p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message)); p.on("console", (m) => { if (m.type() === "error") console.error(`[${label} console]`, m.text()); }); await p.goto(url, { waitUntil: "domcontentloaded" }); await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 }); return p; }; try { // The FIRST visit — how the identifier and the wallet come to exist at all on this // device. Both are obtained here, neither is handed over by the test. first = await arriveAtTheBarrier("returning-first-visit"); at("first visit: the barrier is up"); const gate = first.locator('[data-ng-eventually="access-gate"]'); const [download] = await Promise.all([ first.waitForEvent("download", { timeout: 30000 }), 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(); const [walletPage] = await Promise.all([ fresh.ctx.waitForEvent("page", { timeout: 30000 }), gate.locator('a[target="_blank"]').click(), ]); await importWalletViaFile(walletPage, downloaded, password); await walletPage.close().catch(() => {}); at("first visit: the wallet is imported on this device"); await first.locator('[data-testid="ng-identity-input"]').fill(returning); await first.locator('[data-testid="ng-identity-enter"]').click(); // The APPLICATION navigates, and it has to have DONE so before the broker login is // driven: until then the top-level frame is still the application's own, and // `completeBrokerLogin` would hand back that frame — which then navigates away, so // everything waited for on it waits forever. Cost two runs to see. await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); at("first visit: handed over to the broker"); const firstFrame = await completeBrokerLogin(first, url); await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); // A note, so the second visit can be shown to land in the SAME space rather than // merely displaying the same name. at("first visit: the application is up"); await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite"); at("first visit: a note is written"); // Closed here, not merely at the end: the second visit has to be a fresh page that // finds the identifier where the FIRST one left it, not a tab still holding it — // and one broker page at a time in this profile. await closeQuietly("the first visit's page", () => first!.close()); 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. again = await arriveAtTheBarrier("returning-second-visit"); at("return visit: the barrier is up again"); check( "the barrier still hands out the wallet without asking whether they have it", (await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1, ); check( "the barrier appears again, on a visit where the identifier is already known", again.url().startsWith(url), again.url(), ); const prefilled = await again.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 again.locator('[data-testid="ng-identity-enter"]').click(); await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); check( "confirming the prefilled field is what hands the page over", /nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`), again.url(), ); const backFrame = await completeBrokerLogin(again, url); at("return visit: back inside the broker iframe"); await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim(); check("the round-trip brings them back as the same identity", who.includes(returning), who); // The identity is one space, not two — the failure that skipping the barrier used // to hide was precisely a SECOND virtual space that looked like a working // application. A note written before the round-trip is what tells them apart. const arrived: Actor = { id: returning, frame: backFrame, page: again }; await showScope(arrived, "protected", "Carnet"); // `showScope` settles on an EMPTY list too, and a page this fresh can render one // before its repos have synchronised — so the marker gets its own bounded wait. // Not swallowed: if it never arrives, the check below reads the list and fails on // what is actually there. await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {}); 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); try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } } }); } finally { // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already // 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 */ } } const failed = results.filter((r) => !r.ok).length; const minutes = ((Date.now() - startedAt) / 60000).toFixed(1); console.log( `\n══ Application e2e summary: ${results.length - failed} passed, ${failed} failed, ` + `${results.length} total — ${minutes} min ══`, ); process.exit(failed === 0 ? 0 : 1); } main().catch((e) => { // Anything the journeys did not catch — a refused run lock, a browser lost during setup. // Reported and exited, never left to become an unhandled rejection nobody sees. console.error("[e2e/app] fatal:", e); process.exit(1); });