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:
@@ -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
@@ -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) ──");
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user