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
+2
View File
@@ -4,3 +4,5 @@ dist/
.DS_Store
bun.lockb
bun.lock
e2e/.dist/
*.ngw
+84
View File
@@ -0,0 +1,84 @@
/**
* The contract between the application page and the suite that drives it.
*
* It is declared ONCE and imported by both sides — `indexing-app.ts` implements it,
* `run.ts` calls it — so a method that changes shape breaks the typecheck instead of
* failing at run time inside a browser, where the only symptom would be `undefined is
* not a function` three minutes into a broker crossing.
*
* Everything crossing `frame.evaluate` must be structured-cloneable, which is why every
* member below takes and returns plain strings, numbers and object literals. A `Nuri` is
* a template-literal string type upstream (`did:ng:${string}`), so it crosses as itself;
* it is declared `string` here because a value that has been through structured clone
* carries no proof of its shape, and pretending otherwise is how an unvalidated string
* ends up typed as a reference.
*/
import type { CurationReport, IndexEntry, UnionSubject } from "../src/index";
/** What the leak probe observed — see `run.ts`'s last journey. */
export interface BrokenInboxOutcome {
/** The message `createIndex` rejected with, or `null` if it did not reject. */
readonly rejected: string | null;
/** What `createIndex` returned, on the impossible branch where it did not reject. */
readonly returned: string | null;
/** The documents that appeared in this identity's public store despite the failure. */
readonly appeared: readonly string[];
}
/**
* The acts this application can perform — and ONLY acts an application can perform.
*
* There is no back door onto the library's internals here. The one method that is not
* something an application does (`createIndexWithBrokenInbox`) injects a failure and is
* named for it, because the alternative — leaving the question unanswered — is worse
* than a probe that says what it is.
*/
export interface IndexingBridge {
/** `connecting` → `ready`, or `failed`. */
status(): string;
/** Why the boot failed, or `null`. */
error(): string | null;
/** Who this page signed in as. */
whoami(): string;
/**
* The index this deployment was BUILT to contribute to, read off its own configuration.
*
* An index is an ordinary document; what makes it an index is that an application
* references its NURI in its own source (`src/indexing.ts`). This page is configured
* through its URL rather than through a compiled-in constant, which is the same thing
* one build step earlier — and it is how the reference reaches a SECOND identity
* without the suite handing it over through a variable no application would have.
*/
configuredIndex(): string | null;
/** Create an index in this identity's public store, indexing by `field`. */
createIndex(field: string): Promise<string>;
/** Publish a public document carrying one value for one predicate. */
publishObject(predicate: string, value: string): Promise<string>;
/** Hand the CONFIGURED index a reference to an object. Anyone may. */
referConfigured(object: string): Promise<void>;
/** Hand a NAMED index a reference — used where no identity boundary is crossed. */
referTo(index: string, object: string): Promise<void>;
/** Resolve the references this index received and add what can be added. Owner only. */
curate(index: string): Promise<CurationReport>;
/** The index's entries, ordered by value. */
read(index: string): Promise<IndexEntry[]>;
/** What a document literally holds, straight off `readUnion` — the write-form probe. */
readRaw(doc: string): Promise<UnionSubject[]>;
/** This identity's public documents. How an owner discovers a document it did not keep. */
listPublicDocs(): Promise<string[]>;
/**
* `createIndex` with its inbox step made to fail — everything else real, against the
* real broker. Answers whether a half-created index is left behind.
*/
createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome>;
}
declare global {
interface Window {
__indexing: IndexingBridge;
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* What is SPECIFIC to this repository in the end-to-end setup: the page that carries the
* application, and the name of the wallet its runs mint.
*
* Everything generic — the wallet lifecycle, the broker crossing, per-run profiles,
* bounds, the report shape, the recognition of the known browser failure modes — lives in
* `ng-e2e-helpers` and is used, never reimplemented. That package knows nothing about
* this one and must keep knowing nothing about it: it talks about NextGraph itself, so it
* outlives both the polyfill and this indexing layer.
*/
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
mintWalletProfile,
serveOnEphemeralPort,
type RunProfile,
type WalletCredentials,
} from "ng-e2e-helpers";
const here = path.dirname(fileURLToPath(import.meta.url));
/**
* The throwaway credentials each run mints its own wallet with.
*
* A NAME, not an identity that survives: every run gets a profile of its own and mints
* this wallet into it, so two runs sharing the name share nothing else — which is what
* lets this suite run beside another repository's at the same time, against the same
* broker, without a lock. The password sits here in the clear because it opens a wallet
* that exists for the length of one run and is deleted with the profile holding it.
*/
export const WALLET: WalletCredentials = {
name: "ng-helpers-e2e",
password: "ng-helpers-e2e",
};
/** This run's physical user, in a profile of its own. */
export function mintRunWallet(suite: string): Promise<RunProfile> {
return mintWalletProfile(suite, WALLET);
}
/** `bun build` is a local bundle; a minute is already many times what it takes. */
const BUILD_MS = 60_000;
const ENTRY = path.resolve(here, "indexing-app.ts");
const BUNDLE_OUT = path.resolve(here, ".dist", "indexing-app.js");
export function buildApp(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(here, ".."),
timeout: BUILD_MS,
});
}
/**
* Serve the application the way a deployment would.
*
* An unknown path 404s rather than answering with the page: a catch-all makes a request
* for a file nobody serves look like a perfectly good download, and hides exactly the
* kind of mistake a served asset can carry.
*/
export function serveApp(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html =
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">` +
`<title>ng-helpers indexing — e2e</title></head><body>` +
`<script type="module" src="/indexing-app.js"></script></body></html>`;
return serveOnEphemeralPort((req, res) => {
const route = (req.url ?? "/").split("?")[0];
if (route === "/indexing-app.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else if (route === "/" || route === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
} else {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not served");
}
});
}
+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;
+630
View File
@@ -0,0 +1,630 @@
/**
* `@ng-helpers/indexing` against the REAL broker.
*
* ── What this suite is for ─────────────────────────────────────────────────
* The unit suite proves the indexing rules are consistent with a fake this repository
* wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake
* is the very thing in question. Two claims in particular had never met a broker:
*
* 1. **The write form.** An entry is a triple whose SUBJECT is another document — the
* indexed object — written into the index document's anchored default graph. The
* polyfill's own suites only ever write a document's own subject into itself, so
* nothing had ever asked oxigraph whether a FOREIGN subject survives the round trip.
* `publishObject` here writes the self-subject form with the same primitive, which
* makes it the control: if one round-trips and the other does not, the difference is
* the foreign subject and nothing else.
*
* 2. **A half-created index.** `createIndex` creates a document, writes its descriptor,
* then opens its inbox. If the last step fails the caller gets an exception and no
* reference — but the document exists. The last journey injects that failure and
* asks the broker what was left behind.
*
* ── Two identities, and how the index reference reaches the second ─────────
* The whole point of an index is that STRANGERS contribute to it. So Bob must reach
* Alice's index — and he must reach it the way an application would, not through a
* variable in this file. An index is an ordinary document whose NURI an application
* references in its own source (`src/indexing.ts`), so Bob's page is CONFIGURED with it,
* through its URL: one build step earlier, that is a compiled-in constant. What must
* never happen — and does not happen here — is an inbox address crossing the identity
* boundary through a channel no deployment has.
*
* ── Reading a failure ──────────────────────────────────────────────────────
* A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame
* failure, is the HOST. A failed check carrying an unexpected value is this code. The
* report says which, and the run is repeated rather than anything being loosened.
*/
import {
BROKER_ROUND_TRIP_MS,
NEW_PAGE_MS,
armSuiteDeadline,
browserTrouble,
closeContext,
closeQuietly,
declareSuite,
firstLine,
launchWatchedContext,
measured,
newPage,
setupBrokerPage,
within,
type Prerequisite,
type RunProfile,
} from "ng-e2e-helpers";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/index";
import { WALLET, buildApp, mintRunWallet, serveApp } from "./harness-page";
/**
* The browser types, taken from the helpers that RETURN them rather than imported from
* `playwright` directly.
*
* `ng-e2e-helpers` declares Playwright a PEER dependency — the consumer owns the version,
* because browser binaries have to match the driver. Its files reach this repository as
* symlinks, so TypeScript resolves its `playwright` from where those files really live,
* and importing the driver here as well produced two structurally different copies of
* `BrowserContext`: a context this file had opened could not be handed back to the helper
* that opens contexts. Derived, there is exactly one set of these types — whichever copy
* the helpers speak — and a version skew can no longer express itself as a type error in
* code that is correct.
*/
type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
type Page = Awaited<ReturnType<typeof newPage>>;
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
// ── the domain this suite indexes by ───────────────────────────────────────
//
// A date, so the suite exercises the case the package is built around: an index "by a
// date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string.
const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
/** A predicate an index does NOT curate on — for the object that carries nothing usable. */
const UNRELATED = "urn:ng-helpers-e2e:unrelated";
// ── bounds ─────────────────────────────────────────────────────────────────
//
// Sized to be generous rather than tight. A bound exists to turn a hang into a named
// failure; sized to the median it would instead fail on a slow-but-healthy broker, which
// is the one thing it must never do. The wall clocks of the three reported runs are the
// measurement these should be re-sized from.
/** The bridge appearing on the page — a bundle evaluating, no broker involved. */
const BRIDGE_UP_MS = 60_000;
/** `ensureIdentity` + the session: an identity settled and the connection work run. */
const READY_MS = 180_000;
/** One sign-in: a page, the broker round trip, and the application booting behind it. */
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS;
/** One call across the bridge. The slowest here are curations, which round-trip per deposit. */
const BRIDGE_MS = 4 * 60_000;
/** One journey. The longest holds two sign-ins' worth of work behind it. */
const JOURNEY_MS = 10 * 60_000;
/** The whole run. A budget that cannot interrupt anything is not a budget. */
const SUITE_MS = 30 * 60_000;
// ── the report ─────────────────────────────────────────────────────────────
let actors: BrowserContext | null = null;
const { check, journey, finish } = declareSuite({
label: "ng-helpers indexing e2e",
journeyBound: JOURNEY_MS,
diagnose: async () => (actors === null ? null : browserTrouble("actors", actors)),
journeys: [
{
name: "Alice signs in and creates an index",
checks: [
"Alice signs in and the application knows who she is",
"creating an index answers with a document reference",
"the index document declares the field it indexes by",
],
},
{
name: "Bob signs in configured with Alice's index, and publishes an object",
checks: [
"Bob signs in, configured with the index his application contributes to",
"Bob publishes a public object carrying the indexed field",
"Bob's object reads back carrying the value he wrote",
],
},
{
name: "Bob hands the index a reference, and Alice curates it",
checks: [
"a stranger's deposit into the index's inbox is accepted",
"curation reports Bob's object as indexed",
"the indexed value was read off Bob's object, and never travelled in his deposit",
"the entry is stored under Bob's object's own reference as its subject",
],
},
{
name: "The index reads back, for its owner and for a stranger",
checks: [
"Alice reads exactly one entry, and it is Bob's object",
"Bob, who does not own the index, reads the same entry",
"curating a second time changes nothing, and the index still holds one entry",
],
},
{
name: "An object carrying nothing for the field is not indexed",
checks: [
"curation reports it skipped for want of the field, rather than indexed",
"the index still holds exactly one entry",
],
},
{
name: "An index whose inbox cannot be opened leaves a document behind",
checks: [
"createIndex refuses when the inbox cannot be opened",
"a document was nevertheless created in the owner's public store",
"the leaked document carries a descriptor but accepts no deposit",
],
},
{
name: "A hostile value crosses the round trip as one inert literal",
checks: [
"the object reads back the hostile value byte for byte",
"the index holds it as one entry, and its own descriptor is untouched",
],
},
],
});
/** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */
function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
return measured(what, ms, (bound) => within(what, bound, task));
}
// ── an actor ───────────────────────────────────────────────────────────────
interface Actor {
readonly id: string;
readonly frame: Frame;
readonly page: Page;
}
/**
* Sign an actor in, and wait for its application to be up.
*
* `?ng-id=` is the one channel that survives the broker round trip (the access gate's
* resolution order). `index` rides the same query string when the actor's deployment is
* built to contribute to one.
*/
async function signIn(
ctx: BrowserContext,
appUrl: string,
id: string,
index: string | null,
): Promise<Actor> {
const opened: { page: Page | null } = { page: null };
const query =
`?ng-id=${encodeURIComponent(id)}` +
(index === null ? "" : `&index=${encodeURIComponent(index)}`);
try {
return await measured(`${id}'s sign-in`, SIGN_IN_MS, (bound) =>
within(`${id} to sign in`, bound, async () => {
const page = await measured(`a page for ${id}`, NEW_PAGE_MS, () => newPage(id, ctx));
opened.page = page;
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error(`[${id} console]`, m.text());
});
const frame = await measured(`${id}'s broker round trip`, BROKER_ROUND_TRIP_MS, () =>
setupBrokerPage(page, `${appUrl}/${query}`, WALLET.password),
);
await waitReady(id, frame);
return { id, frame, page };
}),
);
} catch (e) {
if (opened.page !== null) {
await closeQuietly(`${id}'s abandoned sign-in page`, () => opened.page!.close());
}
throw e;
}
}
/** Wait for the application to be up, and say why with ITS reason when it is not. */
async function waitReady(id: string, frame: Frame): Promise<void> {
await step(`${id}'s application bundle`, BRIDGE_UP_MS, () =>
frame.waitForFunction(() => window.__indexing !== undefined, undefined, {
timeout: BRIDGE_UP_MS,
}),
);
await step(`${id}'s identity and session`, READY_MS, () =>
frame.waitForFunction(() => window.__indexing.status() !== "connecting", undefined, {
timeout: READY_MS,
}),
);
const status = await frame.evaluate(() => window.__indexing.status());
if (status !== "ready") {
const why = await frame.evaluate(() => window.__indexing.error());
throw new Error(`[e2e] ${id}'s application did not start (${status}): ${why ?? "no reason given"}`);
}
}
/**
* A journey cannot start without the actor it drives — reported as that, not discovered
* as a timeout on an innocent call.
*
* It takes a THUNK, not the actor: read eagerly, the value would be captured as it was
* before any sign-in happened, and every journey would report an actor that is standing
* right there as missing.
*/
function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
return () => (actor() === null ? `${id} never signed in` : null);
}
// ── the run ────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
finish("the suite exceeded its wall clock"),
);
console.log("[e2e] building the application...");
buildApp();
// This run's own physical user, in a directory of its own — so another repository's
// suite can drive the same broker at the same time without either noticing.
console.log("[e2e] minting this run's wallet...");
const wallet: RunProfile = await mintRunWallet("the indexing suite (e2e/run.ts)");
const stamp = Date.now().toString(36);
const ALICE = `alice-${stamp}`;
const BOB = `bob-${stamp}`;
let ctx: BrowserContext | null = null;
let closeServer: (() => void) | null = null;
try {
const served = await serveApp();
closeServer = served.close;
console.log(`[e2e] application served at ${served.url}`);
ctx = await launchWatchedContext("actors", wallet.dir);
actors = ctx;
let alice: Actor | null = null;
let bob: Actor | null = null;
let index: string | null = null;
let bobsObject: string | null = null;
const aliceIsUp = actorIsUp(ALICE, () => alice);
const bobIsUp = actorIsUp(BOB, () => bob);
const indexExists: Prerequisite = () =>
index === null ? "Alice never created an index" : null;
await journey({
name: "Alice signs in and creates an index",
run: async () => {
alice = await signIn(ctx!, served.url, ALICE, null);
const who = await alice.frame.evaluate(() => window.__indexing.whoami());
check("Alice signs in and the application knows who she is", who.length > 0, `who=${who}`);
index = await step("Alice creating an index", BRIDGE_MS, () =>
alice!.frame.evaluate((f) => window.__indexing.createIndex(f), PUBLISHED_AT),
);
check(
"creating an index answers with a document reference",
typeof index === "string" && index.startsWith("did:ng:"),
`index=${index}`,
);
// The descriptor's round trip — and the first thing the fake could have been
// lying about: the index document is found by an EXACT match on its own NURI as
// a subject, so a broker that returns a subject shaped differently breaks every
// read of every index.
const raw = await step("Alice reading the index document", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const self = raw.find((s) => s.subject === index);
const declared = self?.props[INDEX_FIELD] ?? [];
check(
"the index document declares the field it indexes by",
declared.length === 1 && declared[0] === PUBLISHED_AT && self?.graph === index,
`subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` +
`graph=${self?.graph} declared=${JSON.stringify(declared)}`,
);
},
});
await journey({
name: "Bob signs in configured with Alice's index, and publishes an object",
needs: [indexExists],
run: async () => {
bob = await signIn(ctx!, served.url, BOB, index);
const configured = await bob.frame.evaluate(() => window.__indexing.configuredIndex());
check(
"Bob signs in, configured with the index his application contributes to",
configured === index,
`configured=${configured}`,
);
bobsObject = await step("Bob publishing an object", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[PUBLISHED_AT, "2026-08-17T09:00:00Z"],
),
);
check(
"Bob publishes a public object carrying the indexed field",
typeof bobsObject === "string" && bobsObject.startsWith("did:ng:"),
`object=${bobsObject}`,
);
// The CONTROL for the write form: the same primitive, the document as its own
// subject. This is the shape the polyfill's own suites already exercise.
const raw = await step("Bob reading his own object", BRIDGE_MS, () =>
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), bobsObject!),
);
const self = raw.find((s) => s.subject === bobsObject);
check(
"Bob's object reads back carrying the value he wrote",
(self?.props[PUBLISHED_AT] ?? []).includes("2026-08-17T09:00:00Z"),
`subjects=${raw.length} props=${JSON.stringify(self?.props ?? {})}`,
);
},
});
await journey({
name: "Bob hands the index a reference, and Alice curates it",
needs: [
aliceIsUp,
bobIsUp,
indexExists,
() => (bobsObject === null ? "Bob never published an object" : null),
],
run: async () => {
// Bob names his OWN object, and the index he was configured with. Nothing about
// the value travels: the deposit is the reference and nothing else.
await step("Bob depositing a reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), bobsObject!),
);
const report = await step("Alice curating", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
);
// The deposit is proven ARRIVED, by the only person who can see it. That the post
// did not throw is a weaker claim entirely — it says the call returned, not that
// anything crossed the identity boundary — and asserting it would be asserting a
// constant. Alice reads her own inbox; one outcome means one deposit reached it.
check(
"a stranger's deposit into the index's inbox is accepted",
report.outcomes.length === 1,
`from=${BOB} outcomes=${report.outcomes.length}`,
);
const forBob = report.outcomes.find(
(o) => "object" in o && o.object === bobsObject,
);
check(
"curation reports Bob's object as indexed",
forBob?.result === "indexed",
`outcomes=${JSON.stringify(report.outcomes)}`,
);
check(
"the indexed value was read off Bob's object, and never travelled in his deposit",
forBob?.result === "indexed" && forBob.value === "2026-08-17T09:00:00Z",
`value=${forBob !== undefined && "value" in forBob ? forBob.value : "(none)"}`,
);
// THE WRITE FORM, answered. An entry is a triple whose subject is another
// document, written into this one's anchored default graph. "The write did not
// throw" is not the same claim as "oxigraph stored it": this reads it back.
const raw = await step("Alice reading the index document back", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const entry = raw.find((s) => s.subject === bobsObject);
check(
"the entry is stored under Bob's object's own reference as its subject",
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
},
});
await journey({
name: "The index reads back, for its owner and for a stranger",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
const mine = await step("Alice reading the index", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"Alice reads exactly one entry, and it is Bob's object",
mine.length === 1 && mine[0]?.object === bobsObject,
`entries=${JSON.stringify(mine)}`,
);
// A public index is read by whoever holds its reference — including someone who
// owns neither it nor anything in it. This is the act an application performs.
const theirs = await step("Bob reading the index he does not own", BRIDGE_MS, () =>
bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"Bob, who does not own the index, reads the same entry",
theirs.length === 1 && theirs[0]?.object === bobsObject,
`entries=${JSON.stringify(theirs)}`,
);
// Deposits are never retired, so every run sees every deposit again. Convergence
// is what makes that affordable.
const again = await step("Alice curating a second time", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
);
const still = await step("Alice reading the index again", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"curating a second time changes nothing, and the index still holds one entry",
again.outcomes.every((o) => o.result === "unchanged") && still.length === 1,
`outcomes=${JSON.stringify(again.outcomes)} entries=${still.length}`,
);
},
});
await journey({
name: "An object carrying nothing for the field is not indexed",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// PRESENT but carrying nothing for the field — which is a different answer from
// an object that cannot be read at all, and the reason this object carries a
// predicate rather than being empty: an empty document reads exactly like an
// unreadable one, and resolves as `unresolved`, not `skipped`.
const other = await step("Bob publishing an unrelated object", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[UNRELATED, "nothing to index by"],
),
);
await step("Bob depositing the unrelated reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), other),
);
const report = await step("Alice curating the unrelated reference", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
);
const forOther = report.outcomes.find((o) => "object" in o && o.object === other);
check(
"curation reports it skipped for want of the field, rather than indexed",
forOther?.result === "skipped" && forOther.reason === "no-field",
`outcome=${JSON.stringify(forOther)}`,
);
const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"the index still holds exactly one entry",
entries.length === 1,
`entries=${JSON.stringify(entries)}`,
);
},
});
await journey({
name: "An index whose inbox cannot be opened leaves a document behind",
needs: [aliceIsUp],
run: async () => {
const outcome = await step("Alice creating an index whose inbox fails", BRIDGE_MS, () =>
alice!.frame.evaluate(
(f) => window.__indexing.createIndexWithBrokenInbox(f),
PUBLISHED_AT,
),
);
check(
"createIndex refuses when the inbox cannot be opened",
outcome.rejected !== null && outcome.returned === null,
`rejected=${outcome.rejected} returned=${outcome.returned}`,
);
check(
"a document was nevertheless created in the owner's public store",
outcome.appeared.length === 1,
`appeared=${JSON.stringify(outcome.appeared)}`,
);
// What the leaked document IS: an index in every respect but the one that makes
// it usable. Alice found it in her own store — the only way anyone can, since
// `createIndex` threw its reference away.
const leaked = outcome.appeared[0];
if (leaked === undefined) {
check(
"the leaked document carries a descriptor but accepts no deposit",
false,
"no document appeared, so there was nothing to inspect",
);
return;
}
const raw = await step("Alice reading the leaked document", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), leaked),
);
const declares = (raw.find((s) => s.subject === leaked)?.props[INDEX_FIELD] ?? []).includes(
PUBLISHED_AT,
);
const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => {
try {
await alice!.frame.evaluate(
([i, o]) => window.__indexing.referTo(i!, o!),
[leaked, bobsObject ?? leaked],
);
return null;
} catch (e) {
return firstLine(e);
}
});
check(
"the leaked document carries a descriptor but accepts no deposit",
declares && refused !== null,
`declares=${declares} deposit=${refused ?? "(accepted)"}`,
);
},
});
// LAST, deliberately: if the escaping below turned out not to hold, the damage would
// be to this index, and every check above has already been taken.
await journey({
name: "A hostile value crosses the round trip as one inert literal",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// `src/sparql.ts` carries this package's OWN escaping, because the polyfill
// publishes none. Until now it had only ever been judged by a fake whose SPARQL
// reader was written from the same assumptions — a pair that agrees with itself
// proves nothing about oxigraph. This value closes every construct the escaping
// is responsible for: the literal's own quote, a backslash, the whitespace
// escapes, and a complete injected UPDATE that would empty the index if the
// quote ever escaped its literal.
const hostile =
'a "quoted" part, a \\ backslash, a\nnewline, a\ttab, ' +
'" } ; DROP ALL ; INSERT DATA { <urn:ng-helpers-e2e:pwned> <urn:ng-helpers-e2e:pwned> "';
const object = await step("Bob publishing a hostile value", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[PUBLISHED_AT, hostile],
),
);
const raw = await step("Bob reading the hostile object", BRIDGE_MS, () =>
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), object),
);
const stored = raw.find((s) => s.subject === object)?.props[PUBLISHED_AT] ?? [];
check(
"the object reads back the hostile value byte for byte",
stored.length === 1 && stored[0] === hostile,
`stored=${JSON.stringify(stored)}`,
);
await step("Bob depositing the hostile reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), object),
);
await step("Alice curating the hostile reference", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
);
// Read the index document RAW: it must still declare its own field. An injected
// `DROP ALL` that had taken effect would show up exactly here, as a descriptor
// that is no longer there — and `read()` alone could not tell that apart from an
// ordinary failure.
const after = await step("Alice reading the index after the hostile entry", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? [];
const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? [];
check(
"the index holds it as one entry, and its own descriptor is untouched",
entry.length === 1 && entry[0] === hostile && descriptor.includes(PUBLISHED_AT),
`entry=${JSON.stringify(entry)} descriptor=${JSON.stringify(descriptor)}`,
);
},
});
} finally {
if (ctx !== null) await closeContext("actors", ctx);
if (closeServer !== null) {
await closeQuietly("the application server", async () => closeServer!());
}
wallet.discard();
}
finish(null);
}
void main().catch((e: unknown) => {
console.error("[e2e] fatal:", (e as Error)?.stack ?? e);
finish(firstLine(e));
});
+4
View File
@@ -13,11 +13,15 @@
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
},
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"@types/bun": "latest",
"ng-e2e-helpers": "file:../ng-eventually-js/packages/ng-e2e-helpers",
"playwright": "1.61.1",
"typescript": "^5.6.0"
},
"scripts": {
"test": "bun test",
"test:e2e": "bun run e2e/run.ts",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
}
}
+1 -1
View File
@@ -12,5 +12,5 @@
"isolatedModules": true,
"noEmit": true
},
"include": ["src", "test"]
"include": ["src", "test", "e2e"]
}