test(e2e): les parcours applicatifs passent par l'app d'exemple

Une seconde suite e2e, `packages/sdk/e2e/notebook.ts` (`test:e2e:app`), qui pilote
`examples/notebook` par le DOM — une page de navigateur par identité — contre le même
broker réel.

**Pourquoi une seconde suite plutôt qu'un ajout dans la première.** `run.ts` parle à un
sac de méthodes sur `window.__sdk` : cela prouve que les fonctions TOURNENT, jamais
qu'une application peut s'écrire avec. L'écart a déjà coûté un défaut livré — l'inbox
d'un document était verte ici et inutilisable en pratique, parce que le harnais pouvait
faire traverser une adresse d'une identité à l'autre par une variable, canal qu'aucune
application n'a. Ici, rien ne traverse que ce qui traverse dans la vie : la RÉFÉRENCE
d'une note, recopiée d'un écran, et un identifiant tapé dans un champ.

Quatre parcours, qui se lisent comme des parcours :

- Bob lit la note publique d'Alice depuis sa seule référence — la propriété pour
  laquelle l'émulation du store public existe, vérifiée bout en bout et sans qu'aucune
  clé ne circule ;
- la note protégée d'Alice reste fermée jusqu'à ce qu'elle la partage — même geste côté
  Bob, issue opposée, décidée par où la note se trouve ;
- Bob laisse un message sur la note d'Alice, et seule Alice le lit — il TROUVE l'adresse
  depuis la note, personne ne la lui donne ;
- la liste de chacun ne contient que ses notes.

**Deux étapes quittent `run.ts`** (`documentInboxDeposit`, `capsShareCap`), avec un
commentaire disant où elles sont parties : ce sont des parcours, et ils valent plus joués
sur deux écrans que sur deux appels d'une même page. Ce qui reste là-bas est ce qu'une
application ne fait pas : primitives, caractérisation, régressions de démarrage à froid.

**Trois défauts trouvés en écrivant la suite**, tous côté application et invisibles pour
le harnais : `connectedUser()` devait être attendu à la connexion (sinon une note qu'on
vient de vous partager se lit comme illisible — ce qui ressemble à un problème de droits
alors que c'est un problème de moment) ; une réponse périmée restait affichée à côté
d'une question fraîche ; et changer de portée ne rafraîchissait pas la liste. L'app
affiche désormais la référence de chaque note — ce qu'aucun écran ne montre, aucun
utilisateur ne peut le faire circuler.

Corrigé au passage : le `.gitignore` pointait encore `packages/client/`, si bien que le
commit de renommage a embarqué le profil Playwright de la suite e2e (226 fichiers). Les
chemins sont réalignés et le commit précédent a été amendé — rien n'était poussé.

179 tests unitaires, e2e 40/40 (3,2 min) et applicatif 10/10 (0,7 min).
This commit is contained in:
Sylvain Duchesne
2026-08-07 11:20:58 +02:00
parent 0eb25286c8
commit aa6bbc436e
9 changed files with 364 additions and 130 deletions
+7 -2
View File
@@ -168,8 +168,13 @@ the unused list" is not a reason to investigate it.
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
of the recipient's own inbox processing.
- Tests of the polyfill (against a real broker) live in this repo, so a consuming
app can test its features against a clean, mocked API.
- Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
and the split is deliberate: `packages/sdk/e2e/run.ts` (`test:e2e`) characterises the
primitives and the platform contracts, while `packages/sdk/e2e/notebook.ts`
(`test:e2e:app`) drives the example application through the DOM, one browser page per
identity. Only the second can tell whether an application is *writable* — a harness
can pass a value between two identities through a variable, and an application cannot.
A consuming app can test its own features against a clean, mocked API.
## Status
+15 -4
View File
@@ -6,14 +6,25 @@ It exists for two reasons, and the second is the one that matters.
**It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/sdk` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone.
**It is what the e2e suite is meant to drive** — and does not yet, which this line says out loud rather than implying otherwise. The suite still talks to a bag of methods on `window.__sdk` (`packages/sdk/e2e/sdk-entry.ts`), which proves the functions run but never that an application can be written with them. That gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable something no application can do. This app can only do what an application can do, which is why the applicative scenarios belong here.
**It is what the applicative e2e suite drives** (`packages/sdk/e2e/notebook.ts`, `bun run test:e2e:app`). The other suite talks to a bag of methods on `window.__sdk`, which proves the functions run but never that an application can be written with them — and that gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable, something no application can do. Here each identity is its own browser page, and the only values that cross between them are the ones that cross in life: a note's reference, copied off one screen, and an identifier typed into a field.
It has already paid for itself twice: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried.
It has already paid for itself: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried.
## What it exercises
Signing in, writing notes by scope, listing one's own, reading a note received as a link, handing a reader the key to a private note, opening a note for messages, leaving a message on someone else's note, and reacting to changes.
Signing in, writing notes by scope, listing one's own, reading a note from its bare reference, handing a reader the key to a protected note, opening a note for messages, leaving a message on someone else's note, and reacting to changes.
The four journeys the suite runs, and what each proves:
| Journey | What it proves |
|---|---|
| Bob reads Alice's PUBLIC note from its reference alone | A public store serves its notes' keys — a bare reference is enough, and no key ever crosses |
| Alice's PROTECTED note stays shut until she shares it | The same gesture, the opposite outcome, decided by where the note sits and not by what was sent |
| Bob leaves a message on Alice's note, and only Alice reads it | A depositor FINDS the address from the note itself; depositing grants no reading |
| Each actor's list holds their own notes | The boundary, seen from the only place that matters: the screen |
It has also found three defects of its own, each an application-side one the harness could not see: `connectedUser()` had to be awaited at sign-in (a note just shared with you reads as unreadable otherwise), a stale answer stayed on screen beside a fresh question, and changing the scope did not refresh the list.
## Running it
The e2e suite builds and serves it (`packages/sdk/e2e/`). To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`.
`cd packages/sdk && bun run test:e2e:app` builds it, serves it, and drives it against the real broker. To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`.
+39 -16
View File
@@ -7,13 +7,14 @@
* writes; there is no test scaffolding, no privileged import, no reaching into the
* library's internals. If something is awkward here, it is awkward for everyone.
*
* 2. **It is what the e2e suite drives.** The suite used to talk to a bag of methods on
* `window.__sdk`, which proved the functions ran but never that an application could
* be written with them — and that gap shipped a real defect: a document's inbox was
* green in tests and unusable in practice, because the harness handed the address
* across an identity boundary through a variable. No application can do that. This
* app can only do what an application can do, so a test that passes here means the
* surface is usable, not merely callable.
* 2. **It is what the applicative e2e suite drives** (`packages/sdk/e2e/notebook.ts`).
* The other suite talks to a bag of methods on `window.__sdk`, which proves the
* functions run but never that an application could be written with them — and that
* gap shipped a real defect: a document's inbox was green in tests and unusable in
* practice, because the harness handed the address across an identity boundary
* through a variable. No application can do that. This app can only do what an
* application can do, so a test that passes here means the surface is usable, not
* merely callable.
*
* ── The domain is deliberately thin ───────────────────────────────────────
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
@@ -35,6 +36,7 @@ import {
readUnion,
storeRegistry,
subscribeDoc,
connectedUser,
type Nuri,
type Scope,
// Polyfill-era — these three go away, and they are the whole of what goes away.
@@ -121,15 +123,20 @@ async function myNotes(scope: Scope): Promise<Note[]> {
}
/**
* Read someone else's note from its link.
* Read someone else's note from its REFERENCE.
*
* The link is what circulates in this model — you do not discover a note, you are given
* its link. It arrives as a plain string, from a field or a URL, and goes straight in:
* the library validates it. Nothing to narrow, nothing to cast, and nothing that will
* have to change when the real SDK takes that same string.
* A reference is what circulates — you do not discover a note, someone gives you its
* reference (a message, a URL, a QR code). It carries no key, and that is the point:
* if the note is in a PUBLIC store the store hands its key to whoever asks, so the
* reference is enough; if it is protected, the reference names the note and opens
* nothing, until its owner shares it (see `shareNote`).
*
* It arrives as a plain string, from a field or a URL, and goes straight in: the
* library validates it. Nothing to narrow, nothing to cast, and nothing that will have
* to change when the real SDK takes that same string.
*/
async function readSharedNote(link: string): Promise<Note | null> {
const [note] = await readUnion([link]);
async function readSharedNote(reference: string): Promise<Note | null> {
const [note] = await readUnion([reference]);
if (!note) return null;
return {
doc: note.subject,
@@ -182,11 +189,19 @@ function currentIdentity(): string {
/**
* Sign in. The library shows its access barrier when it needs one; the day the wallet
* supplies the identity, this resolves silently and nothing here changes.
*
* The `connectedUser()` await is not optional decoration, and the applicative e2e is
* what found that out: setting an identity FIRES the connection work — restoring what
* others shared with you, draining your inboxes — but does not wait for it. Render
* before it lands and a note someone just shared reads as unreadable, which looks like
* a permission problem and is a timing one. At migration this becomes the session
* opening, and the await stays exactly where it is.
*/
async function signIn(): Promise<void> {
await ensureIdentity();
await sessionReady;
identity = readIdentityBack();
await connectedUser();
}
/** The library owns the identity; the app asks for it rather than remembering it. */
@@ -213,6 +228,7 @@ async function refresh(): Promise<void> {
<button class="share" data-doc="${n.doc}">partager</button>
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
<button class="msgs" data-doc="${n.doc}">messages</button>
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
</li>`,
)
.join("");
@@ -220,15 +236,20 @@ async function refresh(): Promise<void> {
}
function wire(): void {
// Changing the scope changes which notes are listed — without this the list keeps
// showing the previous scope's notes, which reads as "my note disappeared".
el("scope").addEventListener("change", () => void refresh());
el("write").addEventListener("click", async () => {
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
await refresh();
});
el("openLink").addEventListener("click", async () => {
const note = await readSharedNote(val("link"));
el("openRef").addEventListener("click", async () => {
el("shared").textContent = ""; // never show a previous answer beside a new question
const note = await readSharedNote(val("reference"));
el("shared").textContent = note ? `${note.title}${note.body}` : "(illisible)";
});
el("leave").addEventListener("click", async () => {
el("left").textContent = "";
await leaveMessage(val("onNote") as Nuri, val("message"));
el("left").textContent = "déposé";
});
@@ -236,6 +257,8 @@ function wire(): void {
const target = e.target as HTMLElement;
const doc = target.dataset.doc as Nuri | undefined;
if (!doc) return;
el("shareResult").textContent = "";
el("messages").textContent = "";
if (target.classList.contains("share")) {
await shareNote(doc, val("shareWith"));
el("shareResult").textContent = "partagé";
+3 -2
View File
@@ -12,6 +12,7 @@
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
ul { list-style: none; padding: 0; }
li { padding: 6px 0; border-bottom: 1px solid #eee; }
code.ref { font-size: 11px; color: #888; user-select: all; }
.out { color: #555; font-size: 13px; min-height: 1.2em; }
</style>
</head>
@@ -40,8 +41,8 @@
<fieldset>
<legend>Ouvrir une note reçue</legend>
<input id="link" data-testid="link" placeholder="lien de la note" size="46" />
<button id="openLink" data-testid="open-link">ouvrir</button>
<input id="reference" data-testid="reference" placeholder="référence de la note" size="46" />
<button id="openRef" data-testid="open-reference">ouvrir</button>
<p class="out" id="shared" data-testid="shared"></p>
</fieldset>
-3
View File
@@ -12,9 +12,6 @@
"paths": {
"@ng-eventually/sdk": [
"../../packages/sdk/src/index.ts"
],
"@ng-org/web": [
"../../node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts"
]
}
},
+282
View File
@@ -0,0 +1,282 @@
/**
* 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. */
async function showScope(a: Actor, scope: string): Promise<void> {
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
}
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);
await showScope(a, 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"); // her public shelf, where "Courses" lives
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 () => {
await showScope(alice, "public");
await alice.frame.locator('li:has-text("Courses")').waitFor({ timeout: 60000 });
const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? "";
await showScope(bob, "public");
const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? "";
check("Alice sees her own note", aliceList.includes("Courses"), aliceList.slice(0, 80));
check("Bob's own list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 80) || "(vide)");
});
} 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();
+8 -20
View File
@@ -294,18 +294,11 @@ async function main(): Promise<void> {
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
await sdk(frame, "inboxWatchStop");
});
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check(
"the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data",
r.sameInbox === true &&
r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
);
});
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
// note, and only Alice reads it"). This is the step that motivated that suite: it was
// green here while the feature was unusable, because the harness could hand the inbox
// address across an identity boundary through a variable — a channel no application
// has. Driven through two screens, the address has to be FOUND or the journey fails.
await step("inbox spoof guard", async () => {
const r = await sdk<any>(frame, "inboxSpoofGuard");
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
@@ -388,14 +381,9 @@ async function main(): Promise<void> {
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
);
});
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
check(
"share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
);
});
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
// driven through two screens than through two calls on one page.
// ── accounts (IdentityStore) ────────────────────────────────────────────
console.log("\n── accounts (IdentityStore) ──");
+9 -83
View File
@@ -34,7 +34,6 @@ import {
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/shared-wallet/account-registry";
import { getCaps, getCurrentUser, resetCaps } from "../src/shared-wallet/bootstrap";
import { documentInboxAddress } from "../src/emulated-verifier/branch-registers";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { ensureIdentity } from "@ng-eventually/sdk";
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
@@ -848,88 +847,15 @@ const identity = new IdentityStore(
setCurrentUser(null);
return { ownerView, strangerView, strangerWithLinkView };
},
/**
* Sharing a cap the way the model does it: the owner deposits it into the
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
* "receive" operation exists, and no principal is ever named to the registry.
* Runs against the REAL broker inbox document, so it exercises the whole path.
*/
/**
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
* owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back.
*
* The point of the step is the resolution: the depositor is handed the document's
* BARE reference — the only thing an application circulates — and must find where to
* deposit on its own. It reads the document at all because the document sits in a
* public store, which serves its read cap to whoever asks (`public-store.ts`); no key
* crosses the identity boundary, here or in any real application.
*/
async documentInboxDeposit(ownerId: string, depositorId: string) {
registryInternals.resetRegistryCache();
setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
setCurrentUser(depositorId);
const resolved = await documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
// Opening one on someone else's document must be refused, not silently forked.
let openRefused = false;
try {
await storeRegistry.openDocumentInbox(doc);
} catch {
openRefused = true;
}
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
setCurrentUser(ownerId);
const deposits = await inbox.read(ownerInbox);
// The address is machinery: it must not surface among the document's properties.
const subjects = await readUnion([doc]);
const props = Object.keys(subjects[0]?.props ?? {});
setCurrentUser(null);
return {
sameInbox: resolved === ownerInbox,
openRefused,
deposits: deposits.map((d) => d.payload),
props,
};
},
async capsShareCap(friendId: string) {
const s = await sessionReady;
resetCaps();
// The recipient's OWN inbox — the address a cap is delivered to. Resolved while
// connected as them, since that is who owns it and who may later read it.
// `friendId` is fresh per run: this test's assertions survive accumulated caps, but
// the recipient's durable Links would grow run after run on a persistent wallet,
// making every later `connectedUser()` re-apply a longer and longer history.
setCurrentUser(friendId);
const friendInbox = await registryInternals.userInbox(friendId, "protected");
setCurrentUser("owner-O");
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
getCaps().open(doc, "protected");
setCurrentUser(friendId);
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
setCurrentUser("owner-O");
await inbox.share(doc, friendId);
setCurrentUser(friendId);
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
// `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
// to the consumer as a deposit.
return { before, after, surfacedDeposits: absorbed.length };
},
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
// reads it". This one is WHY that suite exists: it was green here while the
// feature was unusable, because a harness can hand an inbox address across an
// identity boundary through a variable and an application cannot.
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
//
// What stays here is what an application does not do: primitives, characterisation,
// and the cold-start regressions.
// ── accounts (IdentityStore) ─────────────────────────────────────────────
identitySet(id: string) { return identity.set(id); },
+1
View File
@@ -36,6 +36,7 @@
"scripts": {
"test": "bun test",
"test:e2e": "bun run e2e/run.ts",
"test:e2e:app": "bun run e2e/notebook.ts",
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
}
}