Files
ng-eventually/packages/client/e2e/run.ts
T
Sylvain Duchesne 88f396a7ac fix(caps): créer un document en donne le cap, + corriger 9 faits NextGraph
Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.

e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).

Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :

- « chaque document a une inbox native » est FAUX. Seuls les repos de store
  public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
  pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
  n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
  et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
  est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
  transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
  sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
  ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
  `open_branch_`, après `RepoNotFound` ; charger par cap, c'est
  `load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
  (77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
  par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
  dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.

Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
2026-08-03 12:17:40 +02:00

732 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 () => {
// Fresh user per run: an inbox is stable for its owner, so a reused id would
// read back the previous runs' deposits too (the wallet persists).
const r = await sdk<any>(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { 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", "@watcher-" + Date.now());
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", "@friend-" + Date.now());
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);
});