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:
@@ -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
|
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
|
||||||
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
|
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
|
||||||
of the recipient's own inbox processing.
|
of the recipient's own inbox processing.
|
||||||
- Tests of the polyfill (against a real broker) live in this repo, so a consuming
|
- Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
|
||||||
app can test its features against a clean, mocked API.
|
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
|
## Status
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
## 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
|
## 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
@@ -7,13 +7,14 @@
|
|||||||
* writes; there is no test scaffolding, no privileged import, no reaching into the
|
* 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.
|
* 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
|
* 2. **It is what the applicative e2e suite drives** (`packages/sdk/e2e/notebook.ts`).
|
||||||
* `window.__sdk`, which proved the functions ran but never that an application could
|
* The other suite talks to a bag of methods on `window.__sdk`, which proves the
|
||||||
* be written with them — and that gap shipped a real defect: a document's inbox was
|
* functions run but never that an application could be written with them — and that
|
||||||
* green in tests and unusable in practice, because the harness handed the address
|
* gap shipped a real defect: a document's inbox was green in tests and unusable in
|
||||||
* across an identity boundary through a variable. No application can do that. This
|
* practice, because the harness handed the address across an identity boundary
|
||||||
* app can only do what an application can do, so a test that passes here means the
|
* through a variable. No application can do that. This app can only do what an
|
||||||
* surface is usable, not merely callable.
|
* application can do, so a test that passes here means the surface is usable, not
|
||||||
|
* merely callable.
|
||||||
*
|
*
|
||||||
* ── The domain is deliberately thin ───────────────────────────────────────
|
* ── The domain is deliberately thin ───────────────────────────────────────
|
||||||
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
|
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
readUnion,
|
readUnion,
|
||||||
storeRegistry,
|
storeRegistry,
|
||||||
subscribeDoc,
|
subscribeDoc,
|
||||||
|
connectedUser,
|
||||||
type Nuri,
|
type Nuri,
|
||||||
type Scope,
|
type Scope,
|
||||||
// Polyfill-era — these three go away, and they are the whole of what goes away.
|
// 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
|
* A reference is what circulates — you do not discover a note, someone gives you its
|
||||||
* its link. It arrives as a plain string, from a field or a URL, and goes straight in:
|
* reference (a message, a URL, a QR code). It carries no key, and that is the point:
|
||||||
* the library validates it. Nothing to narrow, nothing to cast, and nothing that will
|
* if the note is in a PUBLIC store the store hands its key to whoever asks, so the
|
||||||
* have to change when the real SDK takes that same string.
|
* 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> {
|
async function readSharedNote(reference: string): Promise<Note | null> {
|
||||||
const [note] = await readUnion([link]);
|
const [note] = await readUnion([reference]);
|
||||||
if (!note) return null;
|
if (!note) return null;
|
||||||
return {
|
return {
|
||||||
doc: note.subject,
|
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
|
* 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.
|
* 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> {
|
async function signIn(): Promise<void> {
|
||||||
await ensureIdentity();
|
await ensureIdentity();
|
||||||
await sessionReady;
|
await sessionReady;
|
||||||
identity = readIdentityBack();
|
identity = readIdentityBack();
|
||||||
|
await connectedUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The library owns the identity; the app asks for it rather than remembering it. */
|
/** 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="share" data-doc="${n.doc}">partager</button>
|
||||||
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
|
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
|
||||||
<button class="msgs" data-doc="${n.doc}">messages</button>
|
<button class="msgs" data-doc="${n.doc}">messages</button>
|
||||||
|
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
|
||||||
</li>`,
|
</li>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
@@ -220,15 +236,20 @@ async function refresh(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wire(): 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 () => {
|
el("write").addEventListener("click", async () => {
|
||||||
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
|
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
|
||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
el("openLink").addEventListener("click", async () => {
|
el("openRef").addEventListener("click", async () => {
|
||||||
const note = await readSharedNote(val("link"));
|
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("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)";
|
||||||
});
|
});
|
||||||
el("leave").addEventListener("click", async () => {
|
el("leave").addEventListener("click", async () => {
|
||||||
|
el("left").textContent = "";
|
||||||
await leaveMessage(val("onNote") as Nuri, val("message"));
|
await leaveMessage(val("onNote") as Nuri, val("message"));
|
||||||
el("left").textContent = "déposé";
|
el("left").textContent = "déposé";
|
||||||
});
|
});
|
||||||
@@ -236,6 +257,8 @@ function wire(): void {
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const doc = target.dataset.doc as Nuri | undefined;
|
const doc = target.dataset.doc as Nuri | undefined;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
el("shareResult").textContent = "";
|
||||||
|
el("messages").textContent = "";
|
||||||
if (target.classList.contains("share")) {
|
if (target.classList.contains("share")) {
|
||||||
await shareNote(doc, val("shareWith"));
|
await shareNote(doc, val("shareWith"));
|
||||||
el("shareResult").textContent = "partagé";
|
el("shareResult").textContent = "partagé";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
|
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
|
||||||
ul { list-style: none; padding: 0; }
|
ul { list-style: none; padding: 0; }
|
||||||
li { padding: 6px 0; border-bottom: 1px solid #eee; }
|
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; }
|
.out { color: #555; font-size: 13px; min-height: 1.2em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Ouvrir une note reçue</legend>
|
<legend>Ouvrir une note reçue</legend>
|
||||||
<input id="link" data-testid="link" placeholder="lien de la note" size="46" />
|
<input id="reference" data-testid="reference" placeholder="référence de la note" size="46" />
|
||||||
<button id="openLink" data-testid="open-link">ouvrir</button>
|
<button id="openRef" data-testid="open-reference">ouvrir</button>
|
||||||
<p class="out" id="shared" data-testid="shared"></p>
|
<p class="out" id="shared" data-testid="shared"></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@ng-eventually/sdk": [
|
"@ng-eventually/sdk": [
|
||||||
"../../packages/sdk/src/index.ts"
|
"../../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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}`);
|
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 sdk(frame, "inboxWatchStop");
|
||||||
});
|
});
|
||||||
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
|
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
|
||||||
const t = Date.now();
|
// note, and only Alice reads it"). This is the step that motivated that suite: it was
|
||||||
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
|
// green here while the feature was unusable, because the harness could hand the inbox
|
||||||
check(
|
// address across an identity boundary through a variable — a channel no application
|
||||||
"the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data",
|
// has. Driven through two screens, the address has to be FOUND or the journey fails.
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await step("inbox spoof guard", async () => {
|
await step("inbox spoof guard", async () => {
|
||||||
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
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}`);
|
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)}`,
|
`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 () => {
|
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
|
||||||
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
|
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
|
||||||
check(
|
// driven through two screens than through two calls on one page.
|
||||||
"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}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
||||||
console.log("\n── accounts (IdentityStore) ──");
|
console.log("\n── accounts (IdentityStore) ──");
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
||||||
import * as registryInternals from "../src/shared-wallet/account-registry";
|
import * as registryInternals from "../src/shared-wallet/account-registry";
|
||||||
import { getCaps, getCurrentUser, resetCaps } from "../src/shared-wallet/bootstrap";
|
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 * as virtualUsers from "../src/shared-wallet/virtual-users";
|
||||||
import { ensureIdentity } from "@ng-eventually/sdk";
|
import { ensureIdentity } from "@ng-eventually/sdk";
|
||||||
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
|
// 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);
|
setCurrentUser(null);
|
||||||
return { ownerView, strangerView, strangerWithLinkView };
|
return { ownerView, strangerView, strangerWithLinkView };
|
||||||
},
|
},
|
||||||
/**
|
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
|
||||||
* Sharing a cap the way the model does it: the owner deposits it into the
|
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
|
||||||
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
|
// reads it". This one is WHY that suite exists: it was green here while the
|
||||||
* "receive" operation exists, and no principal is ever named to the registry.
|
// feature was unusable, because a harness can hand an inbox address across an
|
||||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
// identity boundary through a variable and an application cannot.
|
||||||
*/
|
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
|
||||||
/**
|
//
|
||||||
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
|
// What stays here is what an application does not do: primitives, characterisation,
|
||||||
* owner opens it, a third party RESOLVES its address from the document itself and
|
// and the cold-start regressions.
|
||||||
* 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 };
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
||||||
identitySet(id: string) { return identity.set(id); },
|
identitySet(id: string) { return identity.set(id); },
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:e2e": "bun run e2e/run.ts",
|
"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"
|
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user