Files
ng-eventually/packages/sdk/e2e/notebook.ts
T
Sylvain Duchesne b7dc8ca2c3 fix: la suite n'était pas hermétique, et deux tests ne pouvaient pas échouer
Second tour adverse sur le lot D. Trois trouvailles, et une erreur de diagnostic de ma
part qui vaut d'être consignée.

**La suite verte dépendait de l'ordre des fichiers.** `bun test isolation-active
public-store` donnait 5 échecs quand chaque fichier seul était vert — donc un checkout de
CI avec un autre ordre d'inodes livrait rouge. Deux causes distinctes :

- le travail de connexion, lancé sans être attendu par `setCurrentUser`, débordait d'un
  fichier sur le suivant et armait l'émulation. `connectedUser` abandonne désormais dès
  que l'identité pour laquelle il a démarré n'est plus connectée — ce qui est de toute
  façon la bonne sémantique : en amont une session appartient à un utilisateur, et
  changer d'utilisateur est une autre session ;
- et surtout **mon propre test de store public exposait le cap d'un document que
  personne ne détient** — un état que la bibliothèque ne produit jamais. Il ne passait
  que tant que l'émulation était désarmée. Alice crée sa note avant de l'exposer,
  maintenant. Balayage des 21 paires de fichiers : plus aucune ne pollue.

**Le contrôle symétrique ajouté hier ne pouvait pas échouer.** « La liste d'Alice ne
contient pas la note de Bob » lisait un rendu ANTÉRIEUR à l'écriture de Bob : l'attente
de `showScope` était satisfaite au premier sondage par le marqueur déjà à l'écran, sans
synchroniser quoi que ce soit. Alice écrit désormais une note APRÈS celle de Bob —
`writeNote` attend son apparition, donc ce qui suit est un rendu qui post-date. Et le
`.catch` qui avalait le délai d'attente est retiré : une liste qui ne se stabilise jamais
est un échec à voir, pas une dégradation à absorber.

**Le test anti-fork prouvait « pas le premier », pas « le canonique ».** Son minimum
lexicographique était aussi le DERNIER élément, si bien qu'un choix positionnel — la
faute exacte que ce test existe pour attraper — restait vert. Le minimum est déplacé au
milieu ; vérifié par mutation, « prendre le dernier » le fait rougir.

**Mon erreur de diagnostic.** J'ai cru trouver, sous la trouvaille d'ordre, une fuite
entre utilisateurs — les caps d'Alice classés chez Bob — et je l'ai « reproduite ». Le
repro était faux : son faux `ng` ignorait le sujet dans la requête d'inbox, donc l'inbox
de Bob résolvait vers celle d'Alice. Une fois le faux corrigé, la fuite ne se reproduit
plus, ni avec ni sans correctif. Le danger reste réel en lecture du code — trois chemins
classent des caps plusieurs `await` après la garde qui les autorisait — donc
`caps.holderKey`/`learnFor` le ferment par construction, mais les commentaires disent
maintenant ce que c'est : un risque fermé, pas un défaut observé.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:02:45 +02:00

320 lines
16 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 { 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<void>): Promise<void> {
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<Actor> {
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<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 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<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 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<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 out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).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 a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 });
}
async function openForMessages(a: Actor, title: string): Promise<void> {
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<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 a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 });
}
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 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<Actor> {
await a.page.close().catch(() => {});
return signIn(ctx, appUrl, a.id);
}
// ── the journeys ────────────────────────────────────────────────────────────
async function main(): Promise<void> {
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();