737729c9ce
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. »
320 lines
16 KiB
TypeScript
320 lines
16 KiB
TypeScript
/**
|
|
* 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();
|