test(e2e): éprouvé contre un vrai broker, et ce qu'on y apprend

69 tests unitaires, trois rounds d'auto-critique, et rien n'avait jamais tourné
contre un vrai broker. Sept parcours, vingt vérifications, à travers
ng-e2e-helpers — rien de réimplémenté.

La forme d'écriture est ACCEPTÉE par oxigraph, établie en relisant le document
et non parce que la mise à jour n'a pas levé : après curation, readUnion rend
deux sujets, celui de l'index et celui de l'objet de Bob, l'entrée portant sa
valeur. C'était l'une des deux inconnues.

L'autre est REPRODUITE, et c'est un défaut : un openInbox qui échoue en cours de
createIndex laisse un document orphelin. L'appel rejette et ne rend rien, mais
le document existe dans le store public du propriétaire, porte le descripteur,
et refuse les dépôts. Le paquet n'expose pas openInbox, donc il ne peut ni le
réparer ni le supprimer — orphelin permanent. Injecté pour être atteint : rien
de ce que contrôle un appelant ne fait échouer un vrai openDocumentInbox.

Et quatre endroits où la suite unitaire prouve moins qu'elle ne l'annonce, tous
vérifiés. Le plus net : les treize tests d'adaptateur ne chargent JAMAIS le vrai
polyfill. Preuve dure — la copie installée avait perdu un fichier qu'importe
surface/inbox.ts, et 69 sur 69 passaient quand même. Aucun des deux côtés n'a
tort ; c'est l'affirmation « l'adaptateur fonctionne » qui n'était pas testée.
Rien n'a été affaibli, l'e2e est ce qui la teste enfin.

Les trois autres sont de la même nature — une doublure trop faible plutôt qu'un
code faux : ses NURI n'ont pas la forme réelle, elle n'écrit pas la machinerie
que le vrai document porte, et étant une seule Map elle ne peut par construction
jamais révéler un retard de cohérence.

Trois exécutions, 27/27 chacune, autour de 58 secondes, aucune reprise.
This commit is contained in:
Sylvain Duchesne
2026-08-17 10:02:13 +02:00
parent 75378fc5a4
commit f4050b95c0
7 changed files with 1028 additions and 1 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* The application the end-to-end suite drives — written the way a consumer of
* `@ng-helpers/indexing` writes one, and nothing more.
*
* ── Why an application and not a bag of library calls ──────────────────────
* The 69 unit tests in `test/` run against a fake this repository wrote. They prove the
* indexing RULES are consistent; they cannot prove that NextGraph does what the fake
* pretends, because the fake is the thing being asked. This page closes that gap by
* putting the real broker underneath: it imports `@ng-eventually/polyfill` for real,
* crosses the real broker, and calls `indexing(polyfillPort(...))` exactly as an
* application would.
*
* It reaches nothing private. Every import below is a published entry — of the polyfill
* (`configure`, `ensureIdentity`, `init`, `readUnion`, `storeRegistry`) or of this
* package (`indexing`, `polyfillPort`). If something here is awkward, it is awkward for
* every consumer, which is the second reason to write it this way.
*
* ── The one thing here no application does ─────────────────────────────────
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `createIndex`.
* That is a probe, it is named for what it is, and it exists because the question it
* answers — does a failed `openInbox` leave a document behind? — cannot be reached from
* outside: nothing a caller controls makes a real `openDocumentInbox` fail on demand.
* Everything around the injection is real, including the broker and the document.
*/
import {
configure,
ensureIdentity,
init,
readUnion,
storeRegistry,
type Nuri,
type UnionSubject,
} from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web";
import { indexing, polyfillPort } from "../src/index";
import type {
CurationReport,
IndexEntry,
Indexing,
NextGraphPort,
} from "../src/index";
import type { BrokenInboxOutcome, IndexingBridge } from "./bridge";
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ──────────
//
// `sharedWallet` is declared because the access gate wants somewhere to point when it
// has to render, and never used: this suite always enters through the broker's redirect,
// where the wallet is already open in the run's profile. Nothing is served at that path.
configure({
ng: realNg,
useShape: () => undefined, // this application reads through `readUnion`, not the ORM
init: realInit,
sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" },
});
// The library's `init`, not the injected one: it settles the identity BEFORE handing the
// page to the broker, so the round-trip leaves with `?ng-id=` in the address it carries.
// The callback is this application's own business — it keeps the session because
// `polyfillPort` takes a session id, exactly as the real SDK's primitives do.
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init(
(event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) resolve(event.session);
},
true,
[],
);
});
// ── this application's state ───────────────────────────────────────────────
const state: { status: string; error: string | null; who: string } = {
status: "connecting",
error: null,
who: "",
};
let api: Indexing | null = null;
let port: NextGraphPort | null = null;
/** The index this deployment contributes to, read off its own configuration. */
function configuredIndex(): string | null {
return new URLSearchParams(window.location.search).get("index");
}
async function boot(): Promise<void> {
// One await, and it covers everything: the identity settles, the connection work runs,
// and the identity comes back. The application keeps it only to show it.
state.who = await ensureIdentity();
const session = await sessionReady;
port = polyfillPort({ sessionId: session.session_id });
api = indexing(port);
state.status = "ready";
}
void boot().catch((e: unknown) => {
state.status = "failed";
state.error = String((e as Error)?.message ?? e);
});
/** The library, once the page is up. Throws with the boot's own reason if it is not. */
function ready(): Indexing {
if (api === null) {
throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`);
}
return api;
}
function readyPort(): NextGraphPort {
if (port === null) {
throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`);
}
return port;
}
/**
* Wait for a document to appear in this identity's public store.
*
* A store listing is a read like any other, and a document written a moment ago is not
* owed to be in it instantly. Polling is therefore what an owner would actually do, and
* it is bounded: an empty answer at the end is evidence, not a hang.
*/
async function publicDocsAfter(
before: ReadonlySet<string>,
budgetMs: number,
): Promise<readonly string[]> {
const deadline = Date.now() + budgetMs;
let appeared: readonly string[] = [];
for (;;) {
const now = await storeRegistry.listMyEntityDocs("public");
appeared = now.filter((d) => !before.has(d));
if (appeared.length > 0 || Date.now() >= deadline) return appeared;
await new Promise((r) => setTimeout(r, 500));
}
}
// ── the acts ───────────────────────────────────────────────────────────────
const bridge: IndexingBridge = {
status: () => state.status,
error: () => state.error,
whoami: () => state.who,
configuredIndex,
async createIndex(field: string): Promise<string> {
return ready().createIndex(field);
},
/**
* Publish a public document carrying one value for one predicate.
*
* It goes through the SAME primitive the curator writes an entry with
* (`addLiteralProperty`), with the document as its own subject. That makes it the
* CONTROL for the write-form question: if this round-trips and an index entry does
* not, the difference is the foreign subject and nothing else.
*/
async publishObject(predicate: string, value: string): Promise<string> {
const p = readyPort();
const doc = await p.createPublicDocument();
await p.addLiteralProperty(doc, doc, predicate, value);
return doc;
},
async referConfigured(object: string): Promise<void> {
const index = configuredIndex();
if (index === null) {
throw new Error("[e2e] this application was not configured with an index reference");
}
await ready().refer(index, object);
},
async referTo(index: string, object: string): Promise<void> {
await ready().refer(index, object);
},
async curate(index: string): Promise<CurationReport> {
return ready().curate(index);
},
async read(index: string): Promise<IndexEntry[]> {
return ready().read(index);
},
async readRaw(doc: string): Promise<UnionSubject[]> {
return readUnion([doc]);
},
async listPublicDocs(): Promise<string[]> {
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public");
return [...docs];
},
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
const p = readyPort();
const before = new Set<string>(await storeRegistry.listMyEntityDocs("public"));
// Everything real except the inbox step. The failure is injected at the exact moment
// the question is about: after the document exists and carries its descriptor, before
// anyone can deposit into it.
const broken = indexing({
...p,
openInbox: async (): Promise<void> => {
throw new Error("[e2e] injected: the inbox could not be opened");
},
});
let rejected: string | null = null;
let returned: string | null = null;
try {
returned = await broken.createIndex(field);
} catch (e: unknown) {
rejected = String((e as Error)?.message ?? e);
}
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) };
},
};
window.__indexing = bridge;