/** * 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 http from "node:http"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { ensureWallet, launchWalletContext, setupBrokerPage } from "./broker"; 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"); // ── 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 : ""}`); } async function journey(name: string, fn: () => Promise): Promise { console.log(`\n── ${name} ──`); try { await 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, }); } function serveApp(): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const html = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8"); const server = http.createServer((req, res) => { if ((req.url ?? "").startsWith("/app.js")) { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); } else { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); } }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { const port = (server.address() as { port: number }).port; resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); }); }); } // ── 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 { const page = await ctx.newPage(); 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`), so a returning user never sees // the barrier. Here it is also how the suite signs an actor in without typing. 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 a.page.close().catch(() => {}); return signIn(ctx, appUrl, a.id); } // ── the journeys ──────────────────────────────────────────────────────────── async function main(): Promise { console.log("[e2e/app] building the example application..."); buildApp(); console.log("[e2e/app] ensuring the batch wallet..."); await ensureWallet(); const { url, close: closeServer } = await serveApp(); console.log(`[e2e/app] application served at ${url}`); const t = Date.now().toString(36); const ALICE = `alice-${t}`; const BOB = `bob-${t}`; let ctx: BrowserContext | null = null; const startedAt = Date.now(); try { ctx = await launchWalletContext(); 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)); }); } finally { await ctx?.close().catch(() => {}); closeServer(); } 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); } void main();