test(e2e): le parcours qui manquait — Alice revient, et tout est encore à elle

Deux défauts ont été livrés et rapportés par une application, sans que la suite
applicative puisse les voir. Le trou était précis : aucun parcours ne faisait
revenir un PROPRIÉTAIRE après qu'il a ouvert son document aux messages. Le
parcours 3 fait ouvrir Alice et revenir Bob ; Alice, elle, ne se reconnecte
jamais. Il s'arrêtait une reconnexion trop tôt.

Alice écrit deux notes publiques, en ouvre une aux messages, Bob y dépose en la
nommant, puis Alice revient. Elle doit être reconnue, lire la note qu'elle a
faite en ne tenant que sa référence, POUVOIR ENCORE Y ÉCRIRE, et trouver le
message laissé en son absence.

L'écriture compte autant que la lecture : un document public survit à une
relecture après rechargement, sa clé étant retrouvée dans le store, et n'échoue
qu'à l'écriture. Un parcours qui se contenterait de relire aurait manqué la
moitié.

Et il a été vérifié contre le code d'AVANT le correctif, dans un worktree
jetable : les quatre vérifications échouent, sur
« docs.sparqlQuery: refused — the connected user does not hold this document's
cap ». Ce refus apparaît une fois dans chaque journal d'avant et zéro fois dans
les trois d'après. Un parcours qui passe des deux côtés ne prouve rien — c'est
exactement comme ça que ce trou avait survécu.

La reconnexion est vraie : nouvelle page, réalisme JS neuf, donc tous les caches
de module disparaissent pendant que le portefeuille reste intact. Rien n'est
pré-injecté — la référence qu'Alice colle, elle l'a lue sur son propre écran.

Total de vérifications : 34 → 39.
This commit is contained in:
Sylvain Duchesne
2026-08-16 19:04:25 +02:00
parent a33fb8a214
commit 98ee511d3a
+106 -4
View File
@@ -192,7 +192,7 @@ const SIGN_IN_MS = enclosingBound([NEW_PAGE_MS, BROKER_ROUND_TRIP_MS, FIRST_REND
const JOURNEY_MS = 4 * 60 * 1000;
/**
* The batch's wall clock — the last resort behind every bound above, for the wait nobody
* wrapped. A healthy run measures 3.2 min; seven journeys at their own bound would be far
* wrapped. A healthy run measures 3.2 min; eight journeys at their own bound would be far
* more than this, and that is intended: this is not the sum of the journeys, it is the point
* past which a run has stopped being a measurement of anything. What it replaces is a run
* killed by hand at 68 minutes having printed nothing.
@@ -237,6 +237,15 @@ const SUITE: readonly JourneyDeclaration[] = [
name: "Bob leaves a message on Alice's note, and only Alice reads it",
checks: ["Alice reads the message left on her note"],
},
{
name: "Alice comes back after opening her note for messages, and it is all still hers",
checks: [
"coming back is granted, and the application knows her again",
"she goes straight to the note she made and reads it, holding only its reference",
"she can still WRITE to a note she made before she came back",
"the message left while she was away is waiting for her",
],
},
{
name: "each actor's list holds their own notes, and no one else's",
checks: [
@@ -813,7 +822,100 @@ async function main(): Promise<void> {
},
});
// 4. Each actor lists their OWN notes and nothing else — the boundary, seen from
// 4. The reconnection nobody made. Every journey above stops one reconnection short:
// Alice opens her note to messages and it is always somebody ELSE who reopens. So
// what an owner holds on a FRESH session over the same durable wallet was never
// looked at, and two defects shipped behind that gap — both reported by a consuming
// application, neither visible here (fixed 2026-08-16, `f6d1734`):
//
// - connecting replayed only the register of what was SHARED WITH you (the User
// branch's Links) and never the one holding the keys of the documents you MADE
// (the Store branch). A creator who came back and went straight to their own
// note held nothing for it: a public one still READ, because its store hands
// the key to whoever asks, and the WRITE was refused — which is why this
// journey does both, and why a read alone would have missed the defect;
// - and the inbox opened on a document was enumerated at connection without its
// key, so draining it failed — and a failed drain took the whole sign-in down
// with it. An inbox is not consumed by failing, so it refused again at every
// later attempt: not a delayed share, a person locked out for good.
//
// Hence the shape: an owner reaches a state only an owner reaches — a document of
// her own, open to messages, holding somebody else's deposit — and only THEN comes
// back. Nothing is seeded: the reconnection is a new page over the same wallet,
// which is what a person's reload produces, and everything she finds afterwards
// she finds through the application.
await journey({
name: "Alice comes back after opening her note for messages, and it is all still hers",
needs: [aliceIsUp, bobIsUp],
run: async () => {
const before = await must(ALICE, alice);
// TWO notes, and the second one is not a spare: `openDocumentInbox` is idempotent,
// so opening the FIRST one again after coming back would return early and write
// nothing. A note she has not yet opened is what makes "she can still write to a
// document she made" an actual write rather than a lookup.
await writeNote(before, "public", "Boîte à idées", "laissez vos suggestions");
const ideasRef = await referenceOnScreen(before, "Boîte à idées");
await writeNote(before, "public", "Recettes", "tarte aux pommes");
await openForMessages(before, "Boîte à idées");
// Bob reopens so the address published on the note is visible to his session, then
// deposits by naming the NOTE — the published call, no inbox in sight. The
// reference is the one value that crosses, off Alice's screen, as in journey 1.
bob = await reopen(ctx!, url, await must(BOB, bob));
await leaveMessage(bob, ideasRef, "et si on ajoutait un index ?");
// THE reconnection this journey exists for. A new page over the same durable
// wallet: every module-level cache the library holds is gone, and what she finds
// is what connecting restored. `reopen` signs the new page in before closing the
// old one, so a refusal here leaves Alice exactly as she was.
alice = await reopen(ctx!, url, before);
const back = await must(ALICE, alice);
// Granted at all — which is the whole of the second defect. Pre-fix her undrainable
// queue rejected `ensureIdentity()`, the application never rendered, and this
// journey died on the sign-in with none of its checks reported.
const who = ((await back.frame.locator('[data-testid="who"]').textContent()) ?? "").trim();
check("coming back is granted, and the application knows her again", who.includes(ALICE), who);
// Straight to her note, by its reference — a bookmark, a deep link — and
// DELIBERATELY before anything lists her public shelf: listing a scope re-reads its
// Store branch and puts those keys back, so a read taken after one proves only that
// the listing healed it.
const mine = await openReceivedNote(back, ideasRef);
check(
"she goes straight to the note she made and reads it, holding only its reference",
mine.includes("Boîte à idées") && mine.includes("laissez vos suggestions"),
mine,
);
await showScope(back, "public", "Recettes");
// `showScope` settles on an EMPTY list too, and a page this fresh can render one
// before its repos have synchronised — so the note gets its own bounded wait. Not
// swallowed here, unlike journey 7: what follows is a CLICK on that note, and a
// click on an absent one would report Playwright's own default rather than the
// named step that actually waited.
await measured("a note from an earlier visit reappearing", WRITE_NOTE_MS, (ms) =>
back.frame.locator('li:has-text("Recettes")').waitFor({ timeout: ms }),
);
// The WRITE — a statement anchored on a document she created in the session before
// this one, which is the act her own key is needed for and the one a public store
// does not cover.
await openForMessages(back, "Recettes");
// Read off the screen rather than asserted as a literal `true`: what this journey is
// entitled to claim is what the application SAYS happened, and a detail a reader can
// compare against a failing run is worth more than a constant.
const said = ((await back.frame.locator('[data-testid="share-result"]').textContent()) ?? "").trim();
check("she can still WRITE to a note she made before she came back", said.includes("ouverte aux messages"), said);
const left = await readMessages(back, "Boîte à idées");
check(
"the message left while she was away is waiting for her",
left.includes("et si on ajoutait un index ?"),
left,
);
},
});
// 5. Each actor lists their OWN notes and nothing else — the boundary, seen from
// the only place that matters: what the screen shows.
await journey({
name: "each actor's list holds their own notes, and no one else's",
@@ -842,7 +944,7 @@ async function main(): Promise<void> {
},
});
// 5. The path no journey walked: somebody who holds NOTHING. No wallet in the
// 6. The path no journey walked: somebody who holds NOTHING. No wallet in the
// profile, no identifier anywhere, and the application's own address — not the
// broker redirect `signIn()` goes through, which loads the application already
// inside the iframe and so never meets the barrier. Two defects shipped green
@@ -982,7 +1084,7 @@ async function main(): Promise<void> {
},
});
// 6. The visit AFTER the first one, on the application's own address. The barrier used
// 7. The visit AFTER the first one, on the application's own address. The barrier used
// to skip itself here — it asked only when nobody was known — and skipping is
// silent: the page goes straight to the broker, and someone whose browser no longer
// holds the wallet lands on a static dead end with no return path. The wallet is