diff --git a/.gitignore b/.gitignore index c9f990c..3331ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .DS_Store bun.lockb bun.lock +e2e/.dist/ +*.ngw diff --git a/e2e/bridge.ts b/e2e/bridge.ts new file mode 100644 index 0000000..7c59909 --- /dev/null +++ b/e2e/bridge.ts @@ -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; + /** Publish a public document carrying one value for one predicate. */ + publishObject(predicate: string, value: string): Promise; + /** Hand the CONFIGURED index a reference to an object. Anyone may. */ + referConfigured(object: string): Promise; + /** Hand a NAMED index a reference — used where no identity boundary is crossed. */ + referTo(index: string, object: string): Promise; + /** Resolve the references this index received and add what can be added. Owner only. */ + curate(index: string): Promise; + /** The index's entries, ordered by value. */ + read(index: string): Promise; + + /** What a document literally holds, straight off `readUnion` — the write-form probe. */ + readRaw(doc: string): Promise; + /** This identity's public documents. How an owner discovers a document it did not keep. */ + listPublicDocs(): Promise; + + /** + * `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; +} + +declare global { + interface Window { + __indexing: IndexingBridge; + } +} diff --git a/e2e/harness-page.ts b/e2e/harness-page.ts new file mode 100644 index 0000000..905e612 --- /dev/null +++ b/e2e/harness-page.ts @@ -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 { + 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 = + `` + + `ng-helpers indexing — e2e` + + ``; + 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"); + } + }); +} diff --git a/e2e/indexing-app.ts b/e2e/indexing-app.ts new file mode 100644 index 0000000..723df66 --- /dev/null +++ b/e2e/indexing-app.ts @@ -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 { + // 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, + budgetMs: number, +): Promise { + 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 { + 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 { + const p = readyPort(); + const doc = await p.createPublicDocument(); + await p.addLiteralProperty(doc, doc, predicate, value); + return doc; + }, + + async referConfigured(object: string): Promise { + 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 { + await ready().refer(index, object); + }, + + async curate(index: string): Promise { + return ready().curate(index); + }, + + async read(index: string): Promise { + return ready().read(index); + }, + + async readRaw(doc: string): Promise { + return readUnion([doc]); + }, + + async listPublicDocs(): Promise { + const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public"); + return [...docs]; + }, + + async createIndexWithBrokenInbox(field: string): Promise { + const p = readyPort(); + const before = new Set(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 => { + 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; diff --git a/e2e/run.ts b/e2e/run.ts new file mode 100644 index 0000000..5ef1f47 --- /dev/null +++ b/e2e/run.ts @@ -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>; +type Page = Awaited>; +type Frame = Awaited>; + +// ── 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(what: string, ms: number, task: () => Promise): Promise { + 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 { + 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 { + 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 { + 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 { "'; + + 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)); +}); diff --git a/package.json b/package.json index aeb0643..93c919b 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tsconfig.json b/tsconfig.json index 0d2ba5a..912f377 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "isolatedModules": true, "noEmit": true }, - "include": ["src", "test"] + "include": ["src", "test", "e2e"] }