refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph

Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
This commit is contained in:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
+319
View File
@@ -0,0 +1,319 @@
/**
* 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.
*
* The wait is not decoration: the application's `change` handler runs `void refresh()`,
* un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS
* shelf. A suite that asserts "Bob's list does not contain Alice's note" against a list
* that has not re-rendered is green whether isolation holds or not — found adversarially,
* 2026-08-10.
*/
async function showScope(a: Actor, scope: string, settle: string): Promise<void> {
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
// The list is rebuilt wholesale; waiting for the marker the caller expects (or for the
// list to be empty) is the only signal the application offers.
//
// This wait is NOT a synchronisation point when the marker is ALREADY on screen — it
// matches on the first poll and returns before the in-flight `refresh()` has done its
// broker round-trips. A check reading the list right after is then reading the previous
// render. Where a journey needs a FRESH list, it must create its own synchronisation
// point (a write it awaits), not lean on this. Found adversarially, 2026-08-10.
//
// No `.catch` swallowing the timeout either: a list that never settles is a failure to
// see, not a degradation to absorb — swallowing it reinstated the very bug this wait
// was added to fix.
await a.frame
.locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`)
.first()
.waitFor({ timeout: 60000 });
}
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);
// No settle marker to wait for here: the write below is its own synchronisation point,
// and the shelf we are switching to may legitimately be empty or hold anything.
await a.frame.locator('[data-testid="scope"]').selectOption(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", "Courses"); // her public shelf
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 () => {
// POSITIVE CONTROL. Bob writes a public note of his own first — without it his list
// is empty whatever the boundary does, and "it does not contain Alice's note" is
// true for the wrong reason. The assertion has to be able to fail.
await writeNote(bob, "public", "Vélo", "réviser les freins");
await showScope(bob, "public", "Vélo");
const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? "";
// Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see
// it" is read off a stale snapshot and holds whatever the boundary does. Writing a
// note is the synchronisation point the application offers: `writeNote` awaits the
// new entry appearing, so what follows is a render that post-dates Bob's.
await writeNote(alice, "public", "Timbres", "en acheter un carnet");
const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? "";
check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60));
check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60));
check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60));
check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60));
});
} 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();