Files
ng-helpers/e2e/run.ts
T

620 lines
28 KiB
TypeScript

/**
* `@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's next connection curates it",
checks: [
"a stranger's deposit into the index's inbox reached its owner and became an entry",
"the entry is stored under Bob's object's own reference as its subject",
"the indexed value was read off Bob's object, and never travelled in his deposit",
],
},
{
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: [
"the unrelated object is not indexed, and 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's next connection 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!),
);
// NOBODY CURATES: there is nothing on the surface to call. Alice's page connects
// again — what a page load does — and her session processes the inboxes of the
// indexes she owns, this one among them.
await step("Alice connecting again", BRIDGE_MS, () =>
alice!.frame.evaluate(() => window.__indexing.reconnect()),
);
// 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);
// The deposit is proven ARRIVED by its only possible effect: nobody but Alice
// reads that inbox, so an entry for Bob's object means his deposit crossed the
// identity boundary and her session found it. That the post did not throw is a
// weaker claim entirely — it says the call returned, and nothing more.
check(
"a stranger's deposit into the index's inbox reached its owner and became an entry",
entry !== undefined,
`from=${BOB} subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
check(
"the entry is stored under Bob's object's own reference as its subject",
entry !== undefined && Object.hasOwn(entry.props, ENTRY_VALUE),
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
// The value never travelled: a deposit is the reference and nothing else, so its
// presence here means the curation read it off Bob's object itself.
check(
"the indexed value was read off Bob's object, and never travelled in his deposit",
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
`entry=${JSON.stringify(entry?.props ?? {})}`,
);
},
});
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.
await step("Alice connecting a second time", BRIDGE_MS, () =>
alice!.frame.evaluate(() => window.__indexing.reconnect()),
);
const still = await step("Alice reading the index again", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"connecting a second time changes nothing, and the index still holds one entry",
still.length === 1 && still[0]?.object === bobsObject,
`entries=${JSON.stringify(still)}`,
);
},
});
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),
);
await step("Alice connecting after the unrelated reference", BRIDGE_MS, () =>
alice!.frame.evaluate(() => window.__indexing.reconnect()),
);
const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"the unrelated object is not indexed, and the index still holds exactly one entry",
entries.length === 1 && !entries.some((e) => e.object === other),
`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 connecting after the hostile reference", BRIDGE_MS, () =>
alice!.frame.evaluate(() => window.__indexing.reconnect()),
);
// 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));
});