ae9c32e271
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
730 lines
39 KiB
TypeScript
730 lines
39 KiB
TypeScript
/**
|
|
* Real-broker e2e runner for `@ng-eventually/client` — the polyfill's OWN suite,
|
|
* in the SDK domain (no application concepts), with a DEDICATED wallet.
|
|
*
|
|
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
|
|
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/client).
|
|
*
|
|
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
|
|
* the broker iframe on the real broker with that wallet, waits for `window.__sdk`
|
|
* to connect, then drives every polyfill behavior through the bridge and asserts
|
|
* the real-broker outcomes. Each check is event-driven where reactivity matters
|
|
* (waitForFunction on a counter), never a blind sleep.
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Frame, Page, BrowserContext } from "playwright";
|
|
import {
|
|
buildBundle,
|
|
serveHarness,
|
|
ensureWallet,
|
|
launchWalletContext,
|
|
launchCleanProfileContext,
|
|
importWalletViaFile,
|
|
setupBrokerPage,
|
|
} from "./broker";
|
|
|
|
type Check = { name: string; ok: boolean; detail?: string };
|
|
const results: Check[] = [];
|
|
function record(name: string, ok: boolean, detail?: string): void {
|
|
results.push({ name, ok, detail });
|
|
const tag = ok ? "PASS" : "FAIL";
|
|
console.log(` [${tag}] ${name}${detail ? " — " + detail : ""}`);
|
|
}
|
|
function check(name: string, cond: boolean, detail?: string): void {
|
|
record(name, !!cond, detail);
|
|
}
|
|
async function step(name: string, fn: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await fn();
|
|
} catch (e: any) {
|
|
record(name, false, "threw: " + String(e?.message ?? e));
|
|
}
|
|
}
|
|
|
|
// A short helper: call a bridge method inside the iframe.
|
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
|
return frame.evaluate(
|
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
|
[method, args] as const,
|
|
) as Promise<T>;
|
|
}
|
|
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
|
// Same as sdk() but for synchronous getters (no await inside the bridge).
|
|
return frame.evaluate(
|
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
|
[method, args] as const,
|
|
) as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* FAITHFUL reconnection — the real app's reconnect path, NOT export/reimport.
|
|
*
|
|
* Opens a BRAND-NEW page on the SAME persistent wallet context (`ctx`, the profile
|
|
* that already holds the wallet + its local IndexedDB repo cache) and drives a NEW
|
|
* broker login through it. That new login mints a FRESH verifier session (empty
|
|
* `self.repos` at connect) while the page is a fresh SDK-module instance (empty
|
|
* open-repo registry + empty store-registry cache). This is EXACTLY what the app
|
|
* does on re-enter/reload (src/modules/event/steps/data/reconnexion.steps.ts:
|
|
* `this.page.context().newPage()` + `pool.setupBrokerPage`), and is the ONLY faithful
|
|
* cold-open: it does NOT wipe the profile, so it does NOT force the broker to resync
|
|
* every repo from scratch (which export/reimport-into-empty-profile DOES — masking
|
|
* the very cold-read/anti-fork gap under test). The repos are on the broker AND in the
|
|
* profile's cache, but this session's verifier hasn't opened them yet — so the SDK's
|
|
* open-before-read (open-repo.ts) and anti-fork retry (store-registry.ts) are what must
|
|
* bridge the gap. Returns the fresh page + its connected iframe Frame.
|
|
*/
|
|
async function faithfulReconnect(
|
|
ctx: BrowserContext,
|
|
url: string,
|
|
): Promise<{ page: Page; frame: Frame }> {
|
|
const p = await ctx.newPage();
|
|
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
|
|
p.on("console", (m) => {
|
|
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
|
|
});
|
|
const frame = await setupBrokerPage(p, url);
|
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
|
return { page: p, frame };
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
console.log("[e2e] building SDK page bundle...");
|
|
buildBundle();
|
|
console.log("[e2e] ensuring dedicated lib wallet...");
|
|
await ensureWallet();
|
|
const { url, close: closeServer } = await serveHarness();
|
|
console.log(`[e2e] harness served at ${url}`);
|
|
|
|
let ctx: BrowserContext | null = null;
|
|
let page: Page | null = null;
|
|
try {
|
|
ctx = await launchWalletContext();
|
|
page = await ctx.newPage();
|
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") console.error("[iframe console]", m.text());
|
|
});
|
|
|
|
console.log("[e2e] loading SDK page in broker iframe...");
|
|
const frame = await setupBrokerPage(page, url);
|
|
|
|
// Wait for the bridge to exist + the broker session to connect.
|
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
|
timeout: 60000,
|
|
});
|
|
const info = await sdkGet<any>(frame, "sessionInfo");
|
|
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
|
|
|
|
// ── docs primitives ─────────────────────────────────────────────────────
|
|
console.log("\n── docs primitives ──");
|
|
await step("docCreate returns a usable NURI", async () => {
|
|
const nuri = await sdk<string>(frame, "docCreate");
|
|
check("docCreate returns a usable NURI", typeof nuri === "string" && nuri.length > 0, nuri);
|
|
});
|
|
await step("SPARQL graph-behavior characterization (a/b/c)", async () => {
|
|
const rt = await sdk<any>(frame, "docRoundTrip");
|
|
// (a) THE load-bearing assertion fake-ng cannot verify: the anchored default-
|
|
// graph write (no GRAPH clause) round-trips through the real broker's
|
|
// repo_graph_name overlay. This is the canonical shape the lib writes, and
|
|
// what read-model / inbox / store-registry all rely on.
|
|
check(
|
|
"(a) anchored default-graph write (no GRAPH) ROUND-TRIPS",
|
|
rt.anchoredPresent === true,
|
|
`predicates=${JSON.stringify(rt.predicates)}`,
|
|
);
|
|
// (b) FINDING (reported, not gating): on THIS broker version an explicit
|
|
// `INSERT DATA { GRAPH <plainNuri> {…} }` ANCHORED to the same doc ALSO
|
|
// round-trips — readable both via the anchored default-graph read
|
|
// (explicitGraphPresent) AND via an explicit `GRAPH <plainNuri>` read
|
|
// (explicitViaNamedGraph). i.e. when anchored, the plain NURI resolves to the
|
|
// SAME repo graph — there is NO "phantom graph" here. The lib still writes the
|
|
// no-GRAPH default-graph shape as the always-safe canonical convention; this
|
|
// records what the broker actually does so the "phantom graph" comments can be
|
|
// re-checked against this broker version by re-running this harness.
|
|
record(
|
|
"(b) [finding] explicit GRAPH <plainNuri> ANCHORED resolves to the same repo (no phantom graph)",
|
|
true,
|
|
`defaultGraphRead=${rt.explicitGraphPresent} namedGraphRead=${rt.explicitViaNamedGraph} (informational)`,
|
|
);
|
|
// (c) FINDING: the ANCHORLESS `GRAPH ?g { … }` union scan spans EVERY named
|
|
// graph in the session store (it saw BOTH doc A's and doc B's graphs). This is
|
|
// the O(wallet-size) cost the read path avoids by reading each doc with its own
|
|
// anchored default-graph query. Reported, not gating (it is a perf property of
|
|
// the union, not a correctness assertion of the lib's write/read shape).
|
|
const us = rt.unionSpan ?? {};
|
|
record(
|
|
"(c) [finding] anchorless GRAPH ?g scan spans ALL named graphs (O(wallet) union)",
|
|
true,
|
|
us.graphCount === -1
|
|
? `anchorless scan errored: ${us.error} (union claim NOT re-verified here)`
|
|
: `sawDocA=${us.sawDocA} sawDocB=${us.sawDocB} graphCount=${us.graphCount} (both ⇒ union spans all graphs)`,
|
|
);
|
|
});
|
|
|
|
// ── read-model ──────────────────────────────────────────────────────────
|
|
console.log("\n── read-model ──");
|
|
await step("readUnion over N docs → per-doc subjects", async () => {
|
|
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
|
|
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`);
|
|
});
|
|
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
|
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
|
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`);
|
|
});
|
|
await step("readUnion cap gate", async () => {
|
|
const r = await sdk<any>(frame, "readUnionCapGate");
|
|
check("cap gate drops doc for stranger, keeps for owner", r.strangerCount === 0 && r.ownerCount === 1, `stranger=${r.strangerCount} owner=${r.ownerCount}`);
|
|
});
|
|
|
|
// ── reactivity (doc_subscribe) ──────────────────────────────────────────
|
|
console.log("\n── reactivity (doc_subscribe) ──");
|
|
await step("subscribeDoc initial + on-write", async () => {
|
|
const { doc } = await sdk<any>(frame, "subscribeDocStart", "h1");
|
|
// initial state push (event-driven wait)
|
|
await frame.waitForFunction(() => (window as any).__sdk.subscribeCount("h1") >= 1, { timeout: 20000 });
|
|
const initial = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("subscribeDoc fires on initial state", initial >= 1, `count=${initial}`);
|
|
// subsequent real write → another push
|
|
await sdk(frame, "writeTo", doc, "w1");
|
|
await frame.waitForFunction(
|
|
(base) => (window as any).__sdk.subscribeCount("h1") > (base as number),
|
|
initial,
|
|
{ timeout: 20000 },
|
|
);
|
|
const afterWrite = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("subscribeDoc fires on a subsequent write", afterWrite > initial, `count=${afterWrite} (>${initial})`);
|
|
// unsubscribe stops callbacks
|
|
await sdk(frame, "subscribeStop", "h1");
|
|
const frozen = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
await sdk(frame, "writeTo", doc, "w2");
|
|
await page!.waitForTimeout(3000);
|
|
const afterUnsub = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("unsubscribe stops callbacks", afterUnsub === frozen, `count stayed ${afterUnsub}`);
|
|
});
|
|
await step("subscribeDocs per-doc isolation", async () => {
|
|
const { good } = await sdk<any>(frame, "subscribeDocsStart");
|
|
await frame.waitForFunction(() => (window as any).__sdk.multiSubCounts().good >= 1, { timeout: 20000 });
|
|
const base = await sdkGet<any>(frame, "multiSubCounts");
|
|
await sdk(frame, "writeTo", good, "mw1");
|
|
await frame.waitForFunction(
|
|
(b) => (window as any).__sdk.multiSubCounts().good > (b as number),
|
|
base.good,
|
|
{ timeout: 20000 },
|
|
);
|
|
const after = await sdkGet<any>(frame, "multiSubCounts");
|
|
check("good doc fires despite a dead doc in the set", after.good > base.good, `good=${after.good} bad=${after.bad}`);
|
|
await sdk(frame, "multiSubStop");
|
|
});
|
|
|
|
// ── inbox ───────────────────────────────────────────────────────────────
|
|
console.log("\n── inbox ──");
|
|
await step("inbox post → read round-trip", async () => {
|
|
const r = await sdk<any>(frame, "inboxPostRead", { k: "a" }, { k: "b" });
|
|
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
|
|
check(
|
|
"post then read returns both deposits (sorted)",
|
|
r.deposits.length === 2 && payloads.includes('{"k":"a"}') && payloads.includes('{"k":"b"}'),
|
|
`deposits=${r.deposits.length}`,
|
|
);
|
|
});
|
|
await step("inbox watch fires on deposit", async () => {
|
|
await sdk(frame, "inboxWatchStart");
|
|
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
|
|
const base = await sdkGet<any>(frame, "inboxWatchState");
|
|
await sdk(frame, "inboxWatchDeposit", { landed: true });
|
|
await frame.waitForFunction(
|
|
(b) => (window as any).__sdk.inboxWatchState().fires > (b as number),
|
|
base.fires,
|
|
{ timeout: 20000 },
|
|
);
|
|
const after = await sdkGet<any>(frame, "inboxWatchState");
|
|
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
|
await sdk(frame, "inboxWatchStop");
|
|
});
|
|
await step("inbox spoof guard", async () => {
|
|
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
|
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
|
});
|
|
|
|
// ── store-registry ──────────────────────────────────────────────────────
|
|
console.log("\n── store-registry ──");
|
|
await step("ensureAccount idempotent", async () => {
|
|
const r = await sdk<any>(frame, "ensureAccountIdempotent", "@alice-" + Date.now());
|
|
check("ensureAccount returns the same 3 docs on repeat", r.same === true, `same=${r.same}`);
|
|
});
|
|
await step("createEntityDoc + listMyEntityDocs bounded to one account", async () => {
|
|
const t = Date.now();
|
|
const r = await sdk<any>(frame, "entityDocsBounded", "@ea-" + t, "@eb-" + t);
|
|
check("listMyEntityDocs lists A's docs and does NOT leak B's", r.hasA1 && r.hasA2 && !r.leaksB, `A1=${r.hasA1} A2=${r.hasA2} leaksB=${r.leaksB} listA=${r.listA.length}`);
|
|
});
|
|
await step("scope resolvers", async () => {
|
|
const r = await sdk<any>(frame, "scopeResolvers");
|
|
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
|
});
|
|
|
|
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
|
|
// The real cycle: first subscription reads isPending (barrier not yet crossed),
|
|
// then after the broker sync it reaches isSuccess with the seeded datum present.
|
|
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
|
|
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
|
|
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
|
|
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
|
|
const h = "cyc" + Date.now();
|
|
const CLS = "urn:e2e:ws:Event";
|
|
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
|
|
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
|
// Event-driven: wait for the barrier to cross + the datum to land.
|
|
await frame.waitForFunction(
|
|
(hh) => {
|
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
|
return s && s.isSuccess && s.dataLen >= 1;
|
|
},
|
|
h,
|
|
{ timeout: 30000 },
|
|
);
|
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
|
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
|
|
await sdk(frame, "watchShapeStop", h);
|
|
});
|
|
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
|
|
const h = "empty" + Date.now();
|
|
const CLS = "urn:e2e:ws:Event";
|
|
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
|
|
await frame.waitForFunction(
|
|
(hh) => {
|
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
|
return s && s.isSuccess;
|
|
},
|
|
h,
|
|
{ timeout: 30000 },
|
|
);
|
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
|
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
|
await sdk(frame, "watchShapeStop", h);
|
|
});
|
|
|
|
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
|
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
|
await step("read-filter: you read what your keyring holds, nothing else", async () => {
|
|
const r = await sdk<any>(frame, "capsReadFilter");
|
|
// The owner reads the documents whose caps their keyring holds — and NOT the
|
|
// one it does not, even though its NURI is right there in the set.
|
|
const ownerReadsHeld =
|
|
r.ownerView.includes("protected-item") && r.ownerView.includes("public-item");
|
|
const ownerMissesUnheld = !r.ownerView.includes("unheld-item");
|
|
// A stranger holds nothing at all — a bare reference names without reading.
|
|
const strangerReadsNothing = r.strangerView.length === 0;
|
|
// …until the repo link of the PUBLISHED document reaches them.
|
|
const linkOpensPublic =
|
|
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
|
|
check(
|
|
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
|
|
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
|
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
|
|
);
|
|
});
|
|
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
|
|
const r = await sdk<any>(frame, "capsShareCap");
|
|
check(
|
|
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
|
|
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
|
|
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
|
|
);
|
|
});
|
|
|
|
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
|
console.log("\n── accounts (IdentityStore) ──");
|
|
await step("IdentityStore set/get/clear", async () => {
|
|
const setr = await sdk<string>(frame, "identitySet", "@ident-user");
|
|
const got = await sdkGet<string>(frame, "identityGet");
|
|
const cleared = await sdk<string | null>(frame, "identityClear");
|
|
check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`);
|
|
});
|
|
|
|
// ── reconnection cold-start (real-broker regression) ─────────────────────
|
|
// Phase 1 (THIS session): seed — create a per-entity doc under (id, protected)
|
|
// and write a marker triple (`protected` carries participations). Also EXPORT the
|
|
// wallet `.ngw` so phase 2 can re-import the SAME wallet into a CLEAN browser
|
|
// profile.
|
|
//
|
|
// Phase 2 (the faithful reconnect): import the wallet into a BRAND-NEW empty
|
|
// profile dir (no local IndexedDB copy of the repos) and open a fresh SDK session
|
|
// over it — the repos exist on the BROKER but NOT in this profile's local cache,
|
|
// so this is a true reconnect (not a same-profile relaunch, which masks the gap by
|
|
// eagerly rehydrating repos from local storage). Then re-read purely from the
|
|
// wallet and assert the marker comes back.
|
|
//
|
|
// FINDING (recorded, see the digest): on THIS SDK/broker version the marker also
|
|
// comes back WITHOUT the open-before-read heal — the broker-login bootstrap opens
|
|
// the user's repos before the read (the `rawAnchoredNoOpen` detail below shows the
|
|
// bare anchored query already resolves rows). So this test is a real-broker
|
|
// REGRESSION guard for reconnection reads, NOT a fail-without-the-fix proof; the
|
|
// cold-start the fix targets was diagnosed in the app and does not reproduce
|
|
// through this harness's login path.
|
|
console.log("\n── reconnection cold-start (real-broker regression) ──");
|
|
await step("fresh session (clean profile, same wallet) re-reads a persisted entity doc", async () => {
|
|
const reconId = "@recon-" + Date.now();
|
|
const scope = "protected";
|
|
const seed = await sdk<any>(frame, "reconnectSeed", reconId, scope);
|
|
check(
|
|
"seed: entity doc created + listed in the seeding session",
|
|
seed.listedInSeed.includes(seed.entityNuri),
|
|
`entity=${String(seed.entityNuri).slice(0, 24)}… listedInSeed=${seed.listedInSeed.length} origSession=${info?.session_id}`,
|
|
);
|
|
|
|
// Export the wallet file and materialize it for the clean-profile import.
|
|
const exp = await sdk<any>(frame, "exportWalletFile");
|
|
const ngwPath = path.join(os.tmpdir(), `ng-eventually-recon-${Date.now()}.ngw`);
|
|
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
|
|
|
|
let cleanCtx: BrowserContext | null = null;
|
|
let cleanDir: string | null = null;
|
|
let cleanPage: Page | null = null;
|
|
try {
|
|
const launched = await launchCleanProfileContext();
|
|
cleanCtx = launched.ctx;
|
|
cleanDir = launched.dir;
|
|
cleanPage = await cleanCtx.newPage();
|
|
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
|
|
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
|
|
|
|
// Import the SAME wallet into the empty profile (broker-only repos), then open
|
|
// the SDK page in a fresh broker session over it.
|
|
await importWalletViaFile(cleanPage, ngwPath);
|
|
const cleanFrame = await setupBrokerPage(cleanPage, url);
|
|
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
|
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
|
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
|
|
check(
|
|
"clean-profile session connected (fresh verifier, broker-only repos)",
|
|
cleanInfo?.session_id !== undefined && cleanInfo?.session_id !== null,
|
|
`session=${cleanInfo?.session_id}`,
|
|
);
|
|
|
|
const r = await sdk<any>(cleanFrame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
|
|
check(
|
|
"fresh clean-profile session re-reads the persisted marker (reconnection regression)",
|
|
r.markerPresent === true,
|
|
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
|
|
);
|
|
} finally {
|
|
try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ }
|
|
try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ }
|
|
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
// ── CONTRACT 1: faithful reconnect COLD-READ (public + protected) ─────────
|
|
// The real app's reconnect: a FRESH page on the SAME persistent profile + a NEW
|
|
// broker login (fresh verifier session), NOT export/reimport into an empty
|
|
// profile (which forces a full resync and MASKS the cold-open). Session 1 seeds a
|
|
// per-entity doc + marker in BOTH public and protected scopes; a faithful fresh
|
|
// session must relist + re-read its OWN persisted markers, POLLING for the sync —
|
|
// the wait IS the normal path. NB the observed cost is dominated by broker READ
|
|
// latency, not pure sync-lag: ONE reconnectRead cycle (open-repo heal awaits the
|
|
// initial-state push per repo + anti-fork retry budget + anchored readUnion, all
|
|
// round-tripping the real broker) measures ~90-105s. So the poll deadline is a
|
|
// generous MULTI-cycle bound (120s past post-connect) rather than a tight 30s — a
|
|
// single slow cycle must not be mistaken for a sync failure. We report the observed
|
|
// time (the "signal"). If a marker never lands within the bound the check FAILS (a
|
|
// real regression), never a silent 0-row.
|
|
console.log("\n── CONTRACT 1: faithful reconnect cold-read (public + protected) ──");
|
|
await step("faithful reconnect (same profile, new login) re-reads persisted public + protected docs", async () => {
|
|
const reconId = "@recon-faithful-" + Date.now();
|
|
// Seed BOTH scopes in the ORIGINAL session (where the repos are open).
|
|
const seedPub = await sdk<any>(frame, "reconnectSeed", reconId, "public");
|
|
const seedProt = await sdk<any>(frame, "reconnectSeed", reconId, "protected");
|
|
check(
|
|
"seed: public + protected entity docs listed in the seeding session",
|
|
seedPub.listedInSeed.includes(seedPub.entityNuri) && seedProt.listedInSeed.includes(seedProt.entityNuri),
|
|
`pub=${String(seedPub.entityNuri).slice(0, 20)}… prot=${String(seedProt.entityNuri).slice(0, 20)}…`,
|
|
);
|
|
|
|
let rp: Page | null = null;
|
|
try {
|
|
// Faithful reconnect: fresh page on the SAME persistent context + new login.
|
|
const tLoginStart = Date.now();
|
|
const rc = await faithfulReconnect(ctx!, url);
|
|
const loginMs = Date.now() - tLoginStart;
|
|
rp = rc.page;
|
|
const rInfo = await sdkGet<any>(rc.frame, "sessionInfo");
|
|
// Fidelity is STRUCTURAL: a fresh page → fresh iframe → fresh SDK-module
|
|
// instance (empty open-repo + store-registry caches) + a new broker connect
|
|
// over the SAME persistent profile. We assert the reconnect connected; the
|
|
// broker numbers session_id per-connection (may reuse 1), so we REPORT the
|
|
// ids rather than gate on them differing.
|
|
check(
|
|
"reconnect session connected (fresh iframe/verifier over the SAME persistent profile)",
|
|
rInfo?.session_id !== undefined && rInfo?.session_id !== null,
|
|
`reconnectSession=${rInfo?.session_id} origSession=${info?.session_id}`,
|
|
);
|
|
|
|
// Poll each scope up to 30s (from post-connect) for the marker to sync back
|
|
// into THIS fresh session. syncMs is the pure sync lag (login excluded); we
|
|
// report it as the observed sync signal, plus the first-attempt rawNoOpen (the
|
|
// bare anchored read WITHOUT the open-repo heal) which shows whether the heal
|
|
// is load-bearing on this SDK/broker version.
|
|
for (const [scope, seed] of [["public", seedPub], ["protected", seedProt]] as const) {
|
|
let found = false;
|
|
let syncMs = -1;
|
|
let firstRawNoOpen = -99;
|
|
let lastDetail = "";
|
|
const tSyncStart = Date.now();
|
|
const deadline = tSyncStart + 120000;
|
|
let firstAttempt = true;
|
|
while (Date.now() < deadline) {
|
|
const r = await sdk<any>(rc.frame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
|
|
if (firstAttempt) { firstRawNoOpen = r.rawRowCount; firstAttempt = false; }
|
|
lastDetail = `firstRawNoOpen=${firstRawNoOpen} rawNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount}`;
|
|
if (r.markerPresent === true) {
|
|
found = true;
|
|
syncMs = Date.now() - tSyncStart;
|
|
break;
|
|
}
|
|
await rc.page.waitForTimeout(1000);
|
|
}
|
|
check(
|
|
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
|
|
found,
|
|
found
|
|
? `synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`
|
|
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
|
|
);
|
|
}
|
|
} finally {
|
|
try { if (rp) await rp.close(); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
// ── CONTRACT 2: NON-FORK of account across a faithful reconnect ───────────
|
|
// Resolving the SAME identifier after a faithful reconnect must return the SAME
|
|
// account docs (docPublic/docProtected/docPrivate) — never a SECOND provisioning
|
|
// (an account fork), which would strand the first session's data. Session 1
|
|
// provisions the account (records its NURIs); a faithful fresh session re-resolves
|
|
// the SAME id and must return IDENTICAL NURIs. The anti-fork retry bridges the
|
|
// shim-not-yet-synced window; we POLL (generous 120s multi-cycle bound — one
|
|
// accountDocs resolve round-trips the broker's anti-fork retry budget, ~90-100s)
|
|
// and report when the SAME NURIs land (the sync signal). If they never match (or a
|
|
// new set appears) → FAIL.
|
|
console.log("\n── CONTRACT 2: non-fork of account across a faithful reconnect ──");
|
|
await step("resolving the same identifier after a faithful reconnect returns the SAME account docs (no fork)", async () => {
|
|
const forkId = "@nonfork-" + Date.now();
|
|
const orig = await sdk<any>(frame, "accountDocs", forkId);
|
|
check(
|
|
"session 1 provisioned the account (3 scope docs)",
|
|
!!orig.docPublic && !!orig.docProtected && !!orig.docPrivate,
|
|
`pub=${String(orig.docPublic).slice(0, 20)}…`,
|
|
);
|
|
|
|
let rp: Page | null = null;
|
|
try {
|
|
const tLoginStart = Date.now();
|
|
const rc = await faithfulReconnect(ctx!, url);
|
|
const loginMs = Date.now() - tLoginStart;
|
|
rp = rc.page;
|
|
|
|
let same = false;
|
|
let syncMs = -1;
|
|
let last: any = null;
|
|
const tSyncStart = Date.now();
|
|
const deadline = tSyncStart + 120000;
|
|
while (Date.now() < deadline) {
|
|
last = await sdk<any>(rc.frame, "accountDocs", forkId);
|
|
if (
|
|
last.docPublic === orig.docPublic &&
|
|
last.docProtected === orig.docProtected &&
|
|
last.docPrivate === orig.docPrivate
|
|
) {
|
|
same = true;
|
|
syncMs = Date.now() - tSyncStart;
|
|
break;
|
|
}
|
|
await rc.page.waitForTimeout(1000);
|
|
}
|
|
check(
|
|
"[SYNC] fresh session re-resolves the SAME account NURIs (no second provisioning)",
|
|
same,
|
|
same
|
|
? `same account in ${syncMs}ms (reconnect-login ${loginMs}ms)`
|
|
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
|
|
);
|
|
} finally {
|
|
try { if (rp) await rp.close(); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
// ── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──────────────
|
|
//
|
|
// Empirical pin of the implicit contract that open-repo.ts relies on:
|
|
// "the 1st event emitted by doc_subscribe is a State that marks the end of
|
|
// the initial broker sync — after it, presence is guaranteed and absence
|
|
// is definitive."
|
|
//
|
|
// Three sub-contracts:
|
|
// (3a) PRESENCE GUARANTEED — write a triple in session, subscribe, capture
|
|
// the FIRST event; it must be a `State`, and an anchored SPARQL query
|
|
// immediately after must find the triple (no second wait needed).
|
|
// (3b) ABSENCE DEFINITIVE — subscribe to an empty-but-valid doc, capture
|
|
// the FIRST event; it must be a `State` that reflects 0 triples AND
|
|
// must NOT be followed by a late Patch within a grace window.
|
|
// (3c) STATE vs TIMEOUT — the event log carries the real event-type key from
|
|
// the raw AppResponse (`{ V0: { State | Patch | TabInfo | … } }`), so
|
|
// we can distinguish a genuine first-State from a silent timeout.
|
|
//
|
|
// Every wait here is event-driven (waitForFunction on the event count) with a
|
|
// 30s timeout that produces a FAIL, not a silent green.
|
|
console.log("\n── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──");
|
|
|
|
// 3a — PRESENCE GUARANTEED AT FIRST STATE
|
|
await step("(3a) presence guaranteed at first State", async () => {
|
|
const triple = { s: "urn:e2e:state:s", p: "urn:e2e:state:p", o: "state-contract-present" };
|
|
// Write the triple first (same session, write is already committed broker-side).
|
|
const doc = await sdk<string>(frame, "stateProbeWrite", triple);
|
|
|
|
// Subscribe in a fresh call and start timing.
|
|
const tSubscribe = Date.now();
|
|
await sdk(frame, "stateProbeSubscribe", doc);
|
|
|
|
// Wait event-driven for the FIRST event of any type — reveals the push ordering.
|
|
await frame.waitForFunction(
|
|
() => (window as any).__sdk.stateProbeEvents().length >= 1,
|
|
{ timeout: 30000 },
|
|
);
|
|
const firstAnyEventMs = Date.now() - tSubscribe;
|
|
const eventsAfterAny = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
|
|
const firstAnyEvent = eventsAfterAny[0];
|
|
|
|
// 3c: first event MUST have a recognised type key — distinguishes a real push
|
|
// from a synthetic timeout/parse failure. The broker emits TabInfo first, then
|
|
// State (VERIFIED empirically: TabInfo at ~2-5ms, State at ~5-15ms).
|
|
check(
|
|
"(3c) first event has a recognised type key (not a synthetic timeout)",
|
|
firstAnyEvent !== undefined && firstAnyEvent.typeKey !== "unknown" && firstAnyEvent.typeKey !== "parse-error",
|
|
`first-event typeKey=${firstAnyEvent?.typeKey ?? "none"} elapsedMs=${firstAnyEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstAnyEventMs}ms)`,
|
|
);
|
|
|
|
// Now wait specifically for the FIRST State event (may be the 2nd+ overall push —
|
|
// the broker pushes TabInfo before State).
|
|
await frame.waitForFunction(
|
|
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
|
{ timeout: 30000 },
|
|
);
|
|
const firstStateWallMs = Date.now() - tSubscribe;
|
|
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
|
|
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
|
|
|
|
// 3a-i: a State event MUST arrive (not just TabInfo). This is the sync-point
|
|
// barrier — the broker delivers State after syncing up to the broker's heads.
|
|
check(
|
|
"(3a-i) a State event arrives (sync-point barrier confirmed)",
|
|
firstStateEvent !== undefined,
|
|
`stateElapsedMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
|
|
);
|
|
|
|
// 3a-ii: AFTER the State, an anchored SPARQL query must find the triple
|
|
// WITHOUT any additional wait. The State is the sync barrier.
|
|
const q = await sdk<{ rows: number; found: boolean }>(frame, "stateProbeQuery", triple.s, triple.p);
|
|
check(
|
|
"(3a-ii) triple is present in SPARQL query immediately after first State (no extra wait)",
|
|
q.found === true,
|
|
`rows=${q.rows} found=${q.found} stateMs=${firstStateEvent?.elapsedMs ?? "?"}ms`,
|
|
);
|
|
|
|
console.log(
|
|
` [INFO] push ordering: ${eventsAfterState.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
|
|
);
|
|
console.log(
|
|
` [INFO] first-State latency: ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
|
|
);
|
|
await sdk(frame, "stateProbeStop");
|
|
});
|
|
|
|
// 3b — ABSENCE DEFINITIVE AT FIRST STATE
|
|
await step("(3b) absence definitive at first State (empty doc stays empty)", async () => {
|
|
// Create an empty doc and subscribe atomically.
|
|
await sdk<string>(frame, "stateProbeEmptyDoc");
|
|
|
|
// Wait for the first State event (TabInfo arrives first, State second).
|
|
const tSubscribe = Date.now();
|
|
await frame.waitForFunction(
|
|
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
|
{ timeout: 30000 },
|
|
);
|
|
const firstStateWallMs = Date.now() - tSubscribe;
|
|
|
|
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
|
|
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
|
|
|
|
check(
|
|
"(3b-i) a State event arrives for an empty doc (sync barrier fires even for empty)",
|
|
firstStateEvent !== undefined,
|
|
`stateMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
|
|
);
|
|
|
|
// Verify the doc is empty via SPARQL immediately after the State.
|
|
const qEmpty = await sdk<{ rows: number; found: boolean }>(
|
|
frame,
|
|
"stateProbeQuery",
|
|
"urn:e2e:state:s",
|
|
"urn:e2e:state:p",
|
|
);
|
|
check(
|
|
"(3b-ii) SPARQL query immediately after first State confirms the doc is empty",
|
|
qEmpty.found === false && qEmpty.rows === 0,
|
|
`rows=${qEmpty.rows} found=${qEmpty.found}`,
|
|
);
|
|
|
|
// Grace window: wait 5s and verify no data-bearing Patch arrives after the State.
|
|
// A second State is normal (broker may re-push); only a Patch with actual data
|
|
// would violate "absence is definitive". We check the SPARQL result, not event types,
|
|
// because a Patch on an empty doc that stays empty is also fine.
|
|
await page!.waitForTimeout(5000);
|
|
const qAfterGrace = await sdk<{ rows: number; found: boolean }>(
|
|
frame,
|
|
"stateProbeQuery",
|
|
"urn:e2e:state:s",
|
|
"urn:e2e:state:p",
|
|
);
|
|
const eventsAfterGrace = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
|
|
check(
|
|
"(3b-iii) SPARQL still empty after 5s grace window (absence at first State is definitive)",
|
|
!qAfterGrace.found && qAfterGrace.rows === 0,
|
|
`foundAfterGrace=${qAfterGrace.found} events=${JSON.stringify(eventsAfterGrace.map((e) => e.typeKey))}`,
|
|
);
|
|
|
|
console.log(
|
|
` [INFO] push ordering: ${eventsAfterGrace.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
|
|
);
|
|
console.log(
|
|
` [INFO] first-State latency (empty doc): ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
|
|
);
|
|
await sdk(frame, "stateProbeStop");
|
|
});
|
|
} finally {
|
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
|
closeServer();
|
|
}
|
|
|
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
const passed = results.filter((r) => r.ok).length;
|
|
const failed = results.length - passed;
|
|
console.log(`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ══`);
|
|
if (failed > 0) {
|
|
console.log("Failures:");
|
|
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[e2e] fatal:", e);
|
|
process.exit(1);
|
|
});
|