feat(example): une app d'exemple, écrite comme un consommateur

Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait
que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec
— et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test
et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une
identité à l'autre par une variable, ce qu'aucune application ne peut faire.

`examples/notebook` est une application minimale en DOM natif, qui résout
`@ng-eventually/client` comme un consommateur externe (workspace, dépendance
déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application
peut faire.

Elle s'est déjà payée deux fois pendant son écriture :

- `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont
  toujours des références de document. Un consommateur devait donc caster ce
  qu'il venait de lire avant de le repasser — un cast à cet endroit précis
  rouvre la confusion que les types template literal existent pour fermer.
- l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL
  porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui
  de la même personne tapant `erin`. Une seule normalisation désormais, celle
  du registre.

Le domaine est volontairement mince — des notes — mais suffit à exercer le
placement par scope, la possession de caps, le partage dirigé, les inbox par
document et la lecture réactive.

170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais.
This commit is contained in:
Sylvain Duchesne
2026-08-05 18:25:47 +02:00
parent 66a40fbb89
commit d35e735c8b
12 changed files with 814 additions and 19 deletions
+15
View File
@@ -120,6 +120,21 @@ async function main(): Promise<void> {
const info = await sdkGet<any>(frame, "sessionInfo");
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
// ── access gate ─────────────────────────────────────────────────────────
console.log("\n── access gate ──");
await step("the gate asks on a first access, and settles the identity normalized", async () => {
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
check(
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
await step("the gate stays away when the identity is already known", async () => {
const r = await sdk<any>(frame, "accessGateReturningVisit", "erin");
check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── docs primitives ─────────────────────────────────────────────────────
console.log("\n── docs primitives ──");
await step("docCreate returns a usable NURI", async () => {
+50 -1
View File
@@ -19,6 +19,7 @@ import {
configure,
configureStoreRegistry,
setCurrentUser,
getCurrentUser,
capFor,
getCaps,
resetCaps,
@@ -40,7 +41,7 @@ import {
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/shared-wallet/account-registry";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { isNuri } from "@ng-eventually/client";
import { isNuri, ensureIdentity } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = virtualUsers;
@@ -96,6 +97,10 @@ configure({
ng: realNg,
useShape: fakeUseShape,
init: realInit,
// The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import.
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
});
configureStoreRegistry({
@@ -167,6 +172,50 @@ const identity = new IdentityStore(
return { walletName, b64: btoa(bin), len: bytes.length };
},
/**
* THE ACCESS GATE, in a real browser — ported from the consumer's
* `barriere-acces-identifiant` feature, which the library took over with the flow.
*
* Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can
* pin the barrier itself: that it appears on a first access, that entering a value
* settles the identity normalized, and — the one that matters most — that it does NOT
* appear when the identity is already known, since a returning user seeing the barrier
* again is the visible face of the silent bug (a second virtual space).
*/
async accessGateFirstVisit(raw: string) {
setCurrentUser(null);
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
const done = ensureIdentity();
const gate = document.querySelector('[data-ng-eventually="access-gate"]');
const root = gate?.shadowRoot ?? null;
const input = root?.querySelector("input") as HTMLInputElement | null;
const button = root?.querySelector("button.go") as HTMLButtonElement | null;
const shown = input !== null && button !== null;
const disabledWhenEmpty = button?.disabled ?? null;
if (input && button) {
input.value = raw;
input.dispatchEvent(new Event("input"));
button.click();
}
await done;
return {
shown,
disabledWhenEmpty,
identity: getCurrentUser(),
stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
};
},
/** The barrier must stay away once an identity is known. */
async accessGateReturningVisit(known: string) {
setCurrentUser(known);
await ensureIdentity();
return {
shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
identity: getCurrentUser(),
};
},
// ── docs primitives ──────────────────────────────────────────────────────
async docCreate() {
const s = await sessionReady;