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
+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é";