Files
ng-eventually/packages/polyfill/e2e/run.ts
T
Sylvain Duchesne f875047099 fix!: la garde circulaire disparait, et une nouvelle tentative se fait au nom de qui a ete refuse
Troisieme tour de revue adversariale, trois constats, tous reproduits avant
correction.

1. La garde etait CIRCULAIRE, et c'etait structurel. `namedDocumentsReachable`
   demandait si la capacite est DETENUE. Pour un document en store public, le seul
   canal qui puisse jamais la deposer est `fetchReadCap` - l'appel meme que la
   garde retenait. Elle attendait une reponse que seule l'action qu'elle bloquait
   pouvait produire, et toute cette classe restait irrecuperable.

   La bonne question n'est pas une meilleure garde, c'est de RETENTER. Les deux
   facons d'etre refuse se reparent par des choses opposees, et le memo les
   distingue deja : un echec n'est deliberement pas memoise, donc une nouvelle
   tentative redemande vraiment au reseau - c'est la reparation de cette classe,
   pas un cout a eviter ; une absence est memoise, donc une nouvelle tentative ne
   demande a personne et coute un instantane, jusqu'au jour ou la capacite arrive
   d'ailleurs, seule chose qui repare celle-la. La garde est supprimee : le module
   est plus petit qu'au tour precedent.

2. La reouverture faisait un ACCES sous la mauvaise identite. Republier REFILTRE
   une requete existante ; rouvrir EMET un abonnement et une requete au store
   public pour un document que cet abonne a nomme. C'est un acte neuf, pas un
   ancien qui ressort, et l'etat final etait `isSuccess` avec `data: []` pour un
   document illisible - le mensonge que cette tache supprime, par la porte du
   correctif. `refusedFor` retient QUI a ete refuse ; on ne retente que tant que
   c'est encore lui le connecte.

   Etabli plutot qu'affirme : une application ne peut pas changer d'identite en
   cours de session. `ensureIdentity` est le seul appel publie qui en etablit une,
   et `resolveIdentity` commence par `getCurrentUser()`. Corrige quand meme.

3. `Promise.all` court-circuite a la premiere rejection ARRIVEE, et un fetch qui
   rejette n'attend pas la barriere de synchronisation : il double un refus portant
   sur un document nomme avant lui. Le comportement est corrige plutot que le
   commentaire - `allSettled` puis parcours dans l'ordre du scope.

Et le quatrieme controle e2e est retire, pas repare : il passait avec toute la
machinerie de reprise supprimee. Un controle qui ne peut echouer pour aucune
regression de ce qu'il garde se lit comme de la couverture et vaut moins que rien.
2026-09-21 16:28:24 +02:00

960 lines
52 KiB
TypeScript

/**
* Real-broker e2e runner for `@ng-eventually/polyfill` — 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/polyfill).
*
* 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 {
armSuiteDeadline,
closeContext,
closeQuietly,
emptyProfileContext,
importWalletFile,
launchWatchedContext,
newPage,
setupBrokerPage,
within,
type RunProfile,
type Frame,
type Page,
type BrowserContext,
} from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
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);
}
/**
* One step of the batch, under its own deadline.
*
* The bound is what makes the catch honest: recording a FAIL is the right answer for a step
* that fails, but a step that never RETURNS is caught by nothing — and from the outside
* that is indistinguishable from a machine that has stopped.
*/
async function step(name: string, fn: () => Promise<void>): Promise<void> {
try {
await within(`the step "${name}"`, STEP_MS, fn);
} catch (e: any) {
record(name, false, "threw: " + String(e?.message ?? e));
}
}
/**
* Call a bridge method inside the iframe — under a deadline, because `frame.evaluate()`
* has none.
*
* This is the single most important bound in the file: every one of the thirty-odd
* `sdk(...)` calls below is an `evaluate`, and Playwright will wait on one for ever. A
* bridge method that never settles — a broker round-trip that gets no answer — used to
* stop the batch dead with nothing printed and no way to tell which call it was. The name
* carried into the error is the method's own, so the report says which.
*/
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
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 sdk<T>(frame, method, ...args);
}
/**
* 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 newPage("the faithful reconnect", ctx);
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, WALLET.password);
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 };
}
/**
* The batch's own budget, and the measurement that explains an overrun.
*
* Not a `timeout` wrapped around the command from outside: when that fired it killed the
* browser, and the suite reported `Target page, context or browser has been closed` —
* which reads as an application bug and was twice diagnosed as one. A budget belongs to
* the thing that knows what it is spending it on, and it must say so when it runs out.
*
* ENFORCED WHILE IT RUNS, not merely checked at the end. Declared as 45 min and only ever
* asserted after the last step, it could not fire on the one case that matters — a batch
* that never reaches its last step. 25 min is the number now, against a healthy batch of
* ~3.3 min: the drift the old figure was sized against (a physical user growing across
* batches, so an O(size) cold resync) is gone since each batch mints its own user, and a
* budget that cannot interrupt anything is not a budget.
*/
const BATCH_BUDGET_MS = 25 * 60 * 1000;
/**
* One `__sdk` bridge call. The slowest measured on this broker is a reconnect read at
* ~90-105s (open-repo heal + anti-fork retry + anchored read, all round-tripping); four
* minutes is well past that and still names a stuck call in minutes rather than never.
*/
const BRIDGE_MS = 4 * 60 * 1000;
/**
* One step. The longest are the reconnect contracts, which poll for up to 120s per scope
* on top of a fresh broker login — a few minutes when healthy, ten before we call it stuck.
*/
const STEP_MS = 10 * 60 * 1000;
const batchStart = Date.now();
/**
* The slowest cold resynchronisation of the batch — the number that drifted from 250s to
* 286s over a month without anyone looking, because it only ever appeared inside one
* step's detail line. It is the health indicator of the physical user, so it is reported
* with the summary.
*/
let coldSyncMs = 0;
/** Fail with the cause named, rather than letting a killed browser look like a defect. */
function assertWithinBudget(): void {
const spent = Date.now() - batchStart;
if (spent > BATCH_BUDGET_MS) {
throw new Error(
`[e2e] batch budget exceeded (${Math.round(spent / 60000)} min > ` +
`${BATCH_BUDGET_MS / 60000} min). This is almost always the physical user having ` +
"grown: a cold resync is O(its size). Check the cold-sync figure printed above — " +
"it should be stable from batch to batch now that each gets a fresh wallet.",
);
}
}
async function main(): Promise<void> {
armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS);
console.log("[e2e] building SDK page bundle...");
buildBundle();
// This batch's own physical user, in a profile directory of its own. Nothing to serialise
// against another run: there is no shared directory left for two runs to fight over, so a
// suite from a consuming application can drive the same broker at the same time.
console.log("[e2e] minting this batch's wallet...");
const wallet: RunProfile = await mintBatchWallet("the polyfill suite (e2e/run.ts)");
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 launchWatchedContext("sdk-harness", wallet.dir);
page = await newPage("the SDK harness", ctx);
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, WALLET.password);
// 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)}`);
// ── access gate ─────────────────────────────────────────────────────────
console.log("\n── access gate ──");
await step("the gate asks on a first access, and settles the identity normalized", async () => {
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
check(
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
// This harness page runs INSIDE the broker iframe — which is the whole of what makes
// the check below true. Top-level the barrier shows on every load, known identity or
// not, because a top-level page is one redirect away from a dead end for anyone whose
// browser has no wallet. The applicative suite walks that side (`e2e/notebook.ts`).
await step("past the round-trip, a known identity stands the barrier down", async () => {
const r = await sdk<any>(frame, "accessGateIdentityAlreadyKnown", "erin");
check("no barrier inside the broker iframe once the identity is known", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── 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)`,
);
});
// ── the anchored document read ──────────────────────────────────────────
console.log("\n── the anchored document read ──");
await step("an anchored read returns one entry per SUBJECT, with the subject it was written under", async () => {
// 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone
// could not distinguish grouping-by-document from grouping-by-subject, which is why
// this step stayed green while the read conflated them (fixed 2026-08-10).
const r = await sdk<any>(frame, "readDocsOneByOne", 3, false);
const iris: string[] = r.subjectIris ?? [];
check(
"one entry per subject, not per document",
r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"),
`entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`,
);
check(
"each entry carries the subject it was written under, not the document",
iris.every((s) => s.startsWith("urn:e2e:rm:")),
JSON.stringify(iris),
);
check(
"…and its `graph` is the document reference",
(r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")),
JSON.stringify(r.graphs),
);
});
await step("a document that cannot be read REJECTS, and the caller decides to carry on", async () => {
const r = await sdk<any>(frame, "readDocsOneByOne", 2, true);
// 2 documents, the last carrying two subjects → 3 entries. The bad NURI did not
// reduce that, and it is named in `skipped` — the tolerance is the harness's own
// `catch`, not a policy hidden in a primitive (`readUnion` carried one until
// `1.0.0-dev.8`; upstream an unknown repo rejects, so there was nothing to align it on).
check("the readable documents still answer", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
check(
"the unreadable one is REPORTED as skipped, not silently absent",
(r.skipped ?? []).length === 1,
`skipped=${JSON.stringify(r.skipped)}`,
);
});
await step("anchored read cap gate", async () => {
const r = await sdk<any>(frame, "anchoredReadCapGate");
check(
"cap gate REFUSES the stranger, and the owner reads",
r.strangerRefused === true && r.ownerCount === 1,
`strangerRefused=${r.strangerRefused} strangerCount=${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");
});
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
// note, and only Alice reads it"). This is the step that motivated that suite: it was
// green here while the feature was unusable, because the harness could hand the inbox
// address across an identity boundary through a variable — a channel no application
// has. Driven through two screens, the address has to be FOUND or the journey fails.
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)}…`);
});
// ── useShapeQuery (reactive useQuery-shaped read) ───────────────────────
// The real cycle, through the SDK's own shape query since `1.0.0-dev.6`: the first
// subscription reads isPending (the ORM's initial payload has not arrived), then the
// broker answers and it reaches isSuccess with the seeded datum present. A separate
// identity with nothing of that shape reaches isSuccess with data:[] - which over
// `did:ng:i` is the CAPABILITY FILTER doing the bounding, since the engine adds nothing
// to narrow the whole dataset by what the reader may open.
console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──");
await step("useShapeQuery: 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, "useShapeQuerySeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (no initial payload yet)", 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.useShapeQuerySnapshot(hh as string);
return s && s.isSuccess && s.dataLen >= 1;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", 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, "useShapeQueryStop", h);
});
await step("useShapeQuery: empty scope reaches isSuccess with data:[]", async () => {
const h = "empty" + Date.now();
const CLS = "urn:e2e:ws:Event";
await sdk<any>(frame, "useShapeQueryEmptyStart", h, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && s.isSuccess;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
check("nothing of that shape: isSuccess, data:[] (answered-and-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "useShapeQueryStop", h);
});
// The NAMED half of the scope axis, which this suite did not walk until
// `1.0.0-dev.9` - both journeys above read `{ graphs: "did:ng:i" }`, so the barrier
// that `useShapeQuery` now crosses for every document a scope NAMES had no live
// coverage at all. Three readings of the same two documents, one per outcome: the
// owner reads hers, somebody holding nothing is REFUSED rather than told the
// document is empty, and the same somebody opens the PUBLIC one from nothing but its
// reference - which is the barrier's own fetch reaching this broker.
await step("useShapeQuery: a NAMED scope reads, refuses, and opens a public document from a bare reference", async () => {
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "useShapeQueryNamedSeed", CLS);
const stranger = "@wsn-stranger-" + Date.now();
const own = "named-own" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", own, seed.owner, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
own,
{ timeout: 30000 },
);
const ownSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", own);
check(
"the owner naming her own document reads it (the barrier does not break a live named read)",
ownSnap.isSuccess && !ownSnap.isError && ownSnap.titles.includes("named-protected"),
`snap=${JSON.stringify(ownSnap)}`,
);
await sdk(frame, "useShapeQueryStop", own);
const refused = "named-refused" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", refused, stranger, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
refused,
{ timeout: 30000 },
);
const refusedSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
const refusedWhy = await sdkGet<any>(frame, "useShapeQueryError", refused);
// The whole point of the change: NOT `isSuccess` with `data: []`, which is what
// this same call published before `1.0.0-dev.9` - the capability filter emptied
// the result and the snapshot said the document holds nothing of that shape.
check(
"a named document the holder cannot reach is isError, never an empty success",
refusedSnap.isError && !refusedSnap.isSuccess && refusedSnap.dataLen === 0 &&
typeof refusedWhy === "string" && refusedWhy.includes("does not hold this document"),
`snap=${JSON.stringify(refusedSnap)} why=${String(refusedWhy).slice(0, 80)}`,
);
// Left OPEN on purpose for the last reading below: a refused scope standing while
// a cap for some OTHER document lands is exactly what the keyring signal must not
// act on. (`useShapeQueryStop` would also clear the identity, which is why it is
// not called here - the next subscribe is the SAME holder.)
const publicRef = "named-public" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", publicRef, stranger, seed.pub, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
publicRef,
{ timeout: 30000 },
);
const publicSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", publicRef);
// The acquisition the barrier newly performs on this call: the same identity that
// was just refused the protected document opens the public one holding nothing but
// its reference, because the barrier asks the public store and this broker answers.
check(
"a public document named by a holder of nothing opens, through the barrier's own fetch",
publicSnap.isSuccess && !publicSnap.isError && publicSnap.titles.includes("named-public"),
`snap=${JSON.stringify(publicSnap)}`,
);
// A fourth check stood here until 2026-09-21: "a cap landing for ANOTHER document
// leaves a refused scope refused". It was REMOVED rather than repaired, because it
// passed with the whole re-attempt machinery deleted - the outcome is a standing
// refusal either way, so nothing it could observe told the two apart. A check that
// cannot fail for any regression of the thing it guards is worse than no check: it
// reads as coverage. The re-attempt is measured in `test/use-shape-query.test.ts`
// - (v) for the class repaired by re-asking the network, (s) for the one that is
// not and for what a doomed re-attempt costs, (w) for the holder it is made as.
await sdk(frame, "useShapeQueryStop", refused);
await sdk(frame, "useShapeQueryStop", publicRef);
});
// ── 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(
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
);
});
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
// driven through two screens than through two calls on one page.
// ── 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 cleanProfile: RunProfile | null = null;
let cleanPage: Page | null = null;
try {
const launched = await emptyProfileContext("the clean-profile cold read");
cleanCtx = launched.ctx;
cleanProfile = launched.profile;
cleanPage = await newPage("the clean-profile session", cleanCtx);
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 importWalletFile(cleanPage, ngwPath, WALLET.password);
const cleanFrame = await setupBrokerPage(cleanPage, url, WALLET.password);
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 {
if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close());
if (cleanCtx) await closeContext("clean-profile", cleanCtx);
cleanProfile?.discard();
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 + the anchored reads, 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
? ((coldSyncMs = Math.max(coldSyncMs, syncMs)),
`synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`)
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
);
}
} finally {
if (rp) await closeQuietly("the reconnect page", () => rp!.close());
}
});
// ── 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 {
if (rp) await closeQuietly("the reconnect page", () => rp!.close());
}
});
// ── 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 {
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
// gone never resolves, and this `finally` is where that hang swallowed the summary.
if (page) await closeQuietly("the SDK harness page", () => page!.close());
if (ctx) await closeContext("sdk-harness", ctx);
closeServer();
// This run's physical user goes with it. Explicit here and also registered on the way
// out, so a run that is killed mid-batch still takes its profile — and the Chromium
// holding it — with it, instead of leaving both for a host that has to keep running.
wallet.discard();
}
// ── Summary ───────────────────────────────────────────────────────────────
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1);
console.log(
`\n══ Polyfill e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ` +
`— batch ${batchMin} min, slowest cold sync ${Math.round(coldSyncMs / 1000)}s ══`,
);
// A fresh wallet per batch is what should keep the cold sync flat; if it climbs from
// one batch to the next, the per-batch wallet is not being discarded.
assertWithinBudget();
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);
});