refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
|
||||
* `@ng-eventually/polyfill`, fully separate from any consumer app's profile.
|
||||
*
|
||||
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
|
||||
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
|
||||
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
|
||||
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
|
||||
* minimal polyfill page (polyfill-entry.ts) inside the broker iframe.
|
||||
*/
|
||||
|
||||
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
|
||||
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
|
||||
const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready");
|
||||
export const WALLET_NAME = "ng-eventually-e2e";
|
||||
export const WALLET_PASSWORD = "ng-eventually-e2e";
|
||||
|
||||
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
|
||||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
|
||||
|
||||
const LAUNCH_ARGS = [
|
||||
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
||||
"--allow-insecure-localhost",
|
||||
"--disable-web-security",
|
||||
];
|
||||
|
||||
function resolveChromePath(): string | undefined {
|
||||
const p = chromium
|
||||
.executablePath()
|
||||
.replace("chrome-headless-shell", "chrome")
|
||||
.replace("chromium_headless_shell", "chromium");
|
||||
return p.includes("headless") ? undefined : p;
|
||||
}
|
||||
|
||||
export function buildBundle(): void {
|
||||
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||||
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||||
stdio: "pipe",
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
});
|
||||
}
|
||||
|
||||
export function serveHarness(): Promise<{ url: string; close: () => void }> {
|
||||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/polyfill-entry.js") {
|
||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||
res.end(bundle);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
|
||||
* persisted in PROFILE_DIR for the duration of the batch.
|
||||
*
|
||||
* ── One PHYSICAL user per batch, not one forever ──────────────────────────
|
||||
* This used to reuse a single wallet across every run, guarded by a ready marker. That
|
||||
* made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual
|
||||
* identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and
|
||||
* an inbox, and they all land in the SAME physical user. Nothing ever removed them. A
|
||||
* cold resynchronisation is O(the physical user's size) — which this library's own docs
|
||||
* state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single
|
||||
* sync step, against 250s a week earlier, and the drift was invisible because no one
|
||||
* measured it.
|
||||
*
|
||||
* The fresh identities are not the mistake — they are what makes a batch reproducible
|
||||
* (a stable inbox accumulates its past runs' deposits otherwise). The mistake was
|
||||
* keeping the physical user that holds them. So: a new one per batch, which also makes
|
||||
* the cold-sync duration comparable from one run to the next instead of being a number
|
||||
* that only ever grows.
|
||||
*
|
||||
* What this does NOT change: the profile stays persistent WITHIN a batch, because
|
||||
* CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and
|
||||
* the absence of an account fork across it).
|
||||
*/
|
||||
export async function ensureWallet(): Promise<void> {
|
||||
if (fs.existsSync(WALLET_READY_MARKER)) {
|
||||
const age = Date.now() - fs.statSync(WALLET_READY_MARKER).mtimeMs;
|
||||
console.log(
|
||||
`[e2e] discarding the previous batch's wallet (${Math.round(age / 60000)} min old) — ` +
|
||||
"a physical user is per-batch, see ensureWallet",
|
||||
);
|
||||
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
console.log("[e2e] creating this batch's wallet on nextgraph.eu...");
|
||||
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||||
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
||||
page.on("pageerror", () => {});
|
||||
try {
|
||||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const createButton = page.getByText("Create Wallet", { exact: true });
|
||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await createButton.click();
|
||||
|
||||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||||
const acceptButton = page.getByText("I accept", { exact: true });
|
||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await acceptButton.click();
|
||||
|
||||
const usernameInput = page.locator("#username-input");
|
||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||
await usernameInput.fill(WALLET_NAME);
|
||||
const passwordInput = page.locator("#password-input");
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
|
||||
const submitButton = page.getByText("create my wallet", { exact: false });
|
||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// First login → bootstrap the verifier repos from the broker.
|
||||
const walletLink = page.getByText("Click here to login with your wallet");
|
||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const loginPassword = page.locator('input[type="password"]');
|
||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||
await loginPassword.fill(WALLET_PASSWORD);
|
||||
await loginPassword.press("Enter");
|
||||
await page.waitForTimeout(10000);
|
||||
console.log("[e2e] dedicated lib wallet created + bootstrapped");
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString());
|
||||
}
|
||||
|
||||
export async function launchWalletContext(): Promise<BrowserContext> {
|
||||
return chromium.launchPersistentContext(PROFILE_DIR, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
||||
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
|
||||
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
||||
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
||||
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
||||
*
|
||||
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
||||
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
||||
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
||||
* wallet that has never run the app. Caller cleans up ctx + dir.
|
||||
*/
|
||||
export async function createFreshWalletContext(): Promise<{
|
||||
ctx: BrowserContext;
|
||||
dir: string;
|
||||
name: string;
|
||||
}> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
||||
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const ctx = await chromium.launchPersistentContext(dir, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
||||
page.on("pageerror", () => {});
|
||||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const createButton = page.getByText("Create Wallet", { exact: true });
|
||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await createButton.click();
|
||||
|
||||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||||
const acceptButton = page.getByText("I accept", { exact: true });
|
||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await acceptButton.click();
|
||||
|
||||
const usernameInput = page.locator("#username-input");
|
||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||
await usernameInput.fill(name);
|
||||
const passwordInput = page.locator("#password-input");
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
|
||||
const submitButton = page.getByText("create my wallet", { exact: false });
|
||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// First login → bootstrap the verifier repos from the broker (this is what a
|
||||
// brand-new wallet does on its very first unlock).
|
||||
const walletLink = page.getByText("Click here to login with your wallet");
|
||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const loginPassword = page.locator('input[type="password"]');
|
||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||
await loginPassword.fill(WALLET_PASSWORD);
|
||||
await loginPassword.press("Enter");
|
||||
await page.waitForTimeout(10000);
|
||||
await page.close().catch(() => {});
|
||||
return { ctx, dir, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
||||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
||||
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
|
||||
* a session over it starts with an empty `self.repos`. Caller must import the wallet
|
||||
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
|
||||
* and the dir so the caller can clean it up.
|
||||
*/
|
||||
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
|
||||
const ctx = await chromium.launchPersistentContext(dir, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
return { ctx, dir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
|
||||
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
|
||||
* this the profile holds the wallet — but NOT the repos' local cache — so the next
|
||||
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
|
||||
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
|
||||
*/
|
||||
export async function importWalletViaFile(page: Page, ngwPath: string): Promise<void> {
|
||||
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
|
||||
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
|
||||
await page.waitForTimeout(3000);
|
||||
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
|
||||
await page.setInputFiles('input[type=file]', ngwPath);
|
||||
const passwordInput = page.locator('input[type=password]').first();
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
await passwordInput.press("Enter");
|
||||
const confirm = page.getByRole("button", { name: /Confirm/i });
|
||||
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
||||
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
|
||||
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
|
||||
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
|
||||
*/
|
||||
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
|
||||
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||||
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const loginButton = page.getByText("Login", { exact: true });
|
||||
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await loginButton.click();
|
||||
await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1"));
|
||||
const walletLink = page.getByText("Click here to login with your wallet", { exact: false });
|
||||
const loginDeadline = Date.now() + 25000;
|
||||
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
await passwordInput.press("Enter");
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
let appFrame: Frame | null = null;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const f of page.frames()) {
|
||||
if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) {
|
||||
appFrame = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (appFrame) break;
|
||||
for (const iframe of await page.locator("iframe").all()) {
|
||||
const src = await iframe.getAttribute("src");
|
||||
if (src && src.includes("127.0.0.1")) {
|
||||
const el = await iframe.elementHandle();
|
||||
appFrame = (await el?.contentFrame()) ?? null;
|
||||
if (appFrame) break;
|
||||
}
|
||||
}
|
||||
if (appFrame) break;
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (!appFrame) {
|
||||
const frames = page.frames().map((f) => f.url());
|
||||
throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
|
||||
}
|
||||
return appFrame;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* The APPLICATIVE e2e suite — the same broker, driven through the example application.
|
||||
*
|
||||
* ── Why this exists beside `run.ts` ───────────────────────────────────────
|
||||
* `run.ts` drives a bag of methods on `window.__sdk`. That proves the functions RUN; it
|
||||
* cannot prove an application can be written with them, and the difference has already
|
||||
* cost a shipped defect: a document's inbox was green there and unusable in practice,
|
||||
* because the harness handed an address across an identity boundary through a JS
|
||||
* variable — a channel no application has.
|
||||
*
|
||||
* This suite has no such channel. It drives `examples/notebook` through the DOM, one
|
||||
* browser page per identity, and the only things that cross between them are the ones
|
||||
* that cross in reality: a note's REFERENCE (copied from Alice's screen, as a human
|
||||
* would copy it into a message) and an identifier typed into a field. Everything else
|
||||
* each actor must OBTAIN through the application.
|
||||
*
|
||||
* The division of labour with `run.ts`: platform contracts, primitive characterisation
|
||||
* and cold-start regressions stay there — they need privileged access, fresh profiles
|
||||
* and raw SPARQL, and they are about the broker, not about an application. What lives
|
||||
* here is the journeys, and they read as journeys.
|
||||
*
|
||||
* ── Why a bare reference is allowed to cross ──────────────────────────────
|
||||
* Because the model says it circulates: it names a note and grants nothing, and if the
|
||||
* note sits in a public store its cap is served to whoever asks
|
||||
* (`emulated-verifier/public-store.ts`). A test that had to pass a KEY between actors
|
||||
* would be describing something no application can do — that is the line, and it is the
|
||||
* reason the application displays each note's reference: what no screen shows, no user
|
||||
* can circulate.
|
||||
*/
|
||||
|
||||
import { type BrowserContext, type Frame, type Page } from "playwright";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureWallet, launchWalletContext, setupBrokerPage } from "./broker";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook");
|
||||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "notebook.js");
|
||||
|
||||
// ── reporting ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Check = { name: string; ok: boolean; detail?: string };
|
||||
const results: Check[] = [];
|
||||
function check(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
async function journey(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
console.log(`\n── ${name} ──`);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e: any) {
|
||||
check(name, false, "threw: " + String(e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── build + serve the application, exactly as a deployment would ────────────
|
||||
|
||||
function buildApp(): void {
|
||||
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||||
execSync(`bun build ${path.join(APP_DIR, "app.ts")} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||||
stdio: "pipe",
|
||||
cwd: APP_DIR,
|
||||
});
|
||||
}
|
||||
|
||||
function serveApp(): Promise<{ url: string; close: () => void }> {
|
||||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||
const html = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8");
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url ?? "").startsWith("/app.js")) {
|
||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||
res.end(bundle);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── one actor = one page, signed in as one identity ─────────────────────────
|
||||
|
||||
/**
|
||||
* An actor is a browser page carrying its own identity. Nothing is shared between two
|
||||
* actors but the broker and the application's URL — which is what makes a value crossing
|
||||
* from one to the other visible in this file, instead of hidden in a closure.
|
||||
*/
|
||||
interface Actor {
|
||||
id: string;
|
||||
frame: Frame;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<Actor> {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||||
});
|
||||
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
|
||||
// resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees
|
||||
// the barrier. Here it is also how the suite signs an actor in without typing.
|
||||
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
|
||||
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
|
||||
return { id, frame, page };
|
||||
}
|
||||
|
||||
// ── the acts, expressed as the application expresses them ───────────────────
|
||||
|
||||
/**
|
||||
* Show the notes of `scope` — the list is per-scope, so acting on a note means looking at
|
||||
* the right shelf first.
|
||||
*
|
||||
* The wait is not decoration: the application's `change` handler runs `void refresh()`,
|
||||
* un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS
|
||||
* shelf. A suite that asserts "Bob's list does not contain Alice's note" against a list
|
||||
* that has not re-rendered is green whether isolation holds or not — found adversarially,
|
||||
* 2026-08-10.
|
||||
*/
|
||||
async function showScope(a: Actor, scope: string, settle: string): Promise<void> {
|
||||
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
|
||||
// The list is rebuilt wholesale; waiting for the marker the caller expects (or for the
|
||||
// list to be empty) is the only signal the application offers.
|
||||
//
|
||||
// This wait is NOT a synchronisation point when the marker is ALREADY on screen — it
|
||||
// matches on the first poll and returns before the in-flight `refresh()` has done its
|
||||
// broker round-trips. A check reading the list right after is then reading the previous
|
||||
// render. Where a journey needs a FRESH list, it must create its own synchronisation
|
||||
// point (a write it awaits), not lean on this. Found adversarially, 2026-08-10.
|
||||
//
|
||||
// No `.catch` swallowing the timeout either: a list that never settles is a failure to
|
||||
// see, not a degradation to absorb — swallowing it reinstated the very bug this wait
|
||||
// was added to fix.
|
||||
await a.frame
|
||||
.locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`)
|
||||
.first()
|
||||
.waitFor({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function writeNote(a: Actor, scope: string, title: string, body: string): Promise<void> {
|
||||
await a.frame.locator('[data-testid="title"]').fill(title);
|
||||
await a.frame.locator('[data-testid="body"]').fill(body);
|
||||
// No settle marker to wait for here: the write below is its own synchronisation point,
|
||||
// and the shelf we are switching to may legitimately be empty or hold anything.
|
||||
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
|
||||
await a.frame.locator('[data-testid="write"]').click();
|
||||
await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 });
|
||||
}
|
||||
|
||||
/** The reference the application SHOWS for a note — what a human would copy out. */
|
||||
async function referenceOnScreen(a: Actor, title: string): Promise<string> {
|
||||
return (await a.frame.locator(`li:has-text("${title}") code.ref`).textContent())?.trim() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste a reference and open it. The application blanks its answer before reading, so
|
||||
* waiting for a NON-EMPTY answer here cannot be satisfied by the previous one — a trap
|
||||
* this suite fell into on its first run, where a stale "readable" made an unreadable
|
||||
* note look readable.
|
||||
*/
|
||||
async function openReceivedNote(a: Actor, reference: string): Promise<string> {
|
||||
await a.frame.locator('[data-testid="reference"]').fill(reference);
|
||||
await a.frame.locator('[data-testid="open-reference"]').click();
|
||||
const out = a.frame.locator('[data-testid="shared"]');
|
||||
await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {});
|
||||
return (await out.textContent())?.trim() ?? "";
|
||||
}
|
||||
|
||||
async function shareNoteWith(a: Actor, title: string, withId: string): Promise<void> {
|
||||
await a.frame.locator('[data-testid="share-with"]').fill(withId);
|
||||
await a.frame.locator(`li:has-text("${title}") button.share`).click();
|
||||
await a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function openForMessages(a: Actor, title: string): Promise<void> {
|
||||
await a.frame.locator(`li:has-text("${title}") button.open`).click();
|
||||
await a.frame
|
||||
.locator('[data-testid="share-result"]')
|
||||
.filter({ hasText: "ouverte aux messages" })
|
||||
.waitFor({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function leaveMessage(a: Actor, reference: string, text: string): Promise<void> {
|
||||
await a.frame.locator('[data-testid="on-note"]').fill(reference);
|
||||
await a.frame.locator('[data-testid="message"]').fill(text);
|
||||
await a.frame.locator('[data-testid="leave"]').click();
|
||||
await a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function readMessages(a: Actor, title: string): Promise<string> {
|
||||
await a.frame.locator(`li:has-text("${title}") button.msgs`).click();
|
||||
const out = a.frame.locator('[data-testid="messages"]');
|
||||
await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {});
|
||||
return (await out.textContent())?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Reload the page: what a user does, and what makes a durable fact distinguishable
|
||||
* from one that only lived in this tab's memory. */
|
||||
async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise<Actor> {
|
||||
await a.page.close().catch(() => {});
|
||||
return signIn(ctx, appUrl, a.id);
|
||||
}
|
||||
|
||||
// ── the journeys ────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[e2e/app] building the example application...");
|
||||
buildApp();
|
||||
console.log("[e2e/app] ensuring the batch wallet...");
|
||||
await ensureWallet();
|
||||
const { url, close: closeServer } = await serveApp();
|
||||
console.log(`[e2e/app] application served at ${url}`);
|
||||
|
||||
const t = Date.now().toString(36);
|
||||
const ALICE = `alice-${t}`;
|
||||
const BOB = `bob-${t}`;
|
||||
let ctx: BrowserContext | null = null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
ctx = await launchWalletContext();
|
||||
const alice = await signIn(ctx, url, ALICE);
|
||||
check("Alice signs in and the application knows who she is", true, `who=${ALICE}`);
|
||||
const bob = await signIn(ctx, url, BOB);
|
||||
check("Bob signs in, in his own space", true, `who=${BOB}`);
|
||||
|
||||
// 1. A public note travels on its reference alone — the property the public-store
|
||||
// emulation exists for. Nothing but the reference crosses, and no key does.
|
||||
let publicRef = "";
|
||||
await journey("Bob reads Alice's public note from its reference alone", async () => {
|
||||
await writeNote(alice, "public", "Courses", "pain, café");
|
||||
publicRef = await referenceOnScreen(alice, "Courses");
|
||||
check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef);
|
||||
// The one value that crosses, and it crosses the way it would in life: copied off
|
||||
// one screen, pasted into another. It carries no key.
|
||||
const read = await openReceivedNote(bob, publicRef);
|
||||
check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read);
|
||||
check("the reference carried no key", !publicRef.includes(":r:"), publicRef);
|
||||
});
|
||||
|
||||
// 2. A protected note does NOT travel on its reference — until its owner shares it.
|
||||
// Same gesture on Bob's side, opposite outcome, decided by where the note sits.
|
||||
let secretRef = "";
|
||||
await journey("Alice's protected note stays shut until she gives Bob the key", async () => {
|
||||
await writeNote(alice, "protected", "Anniversaire", "surprise pour Bob");
|
||||
secretRef = await referenceOnScreen(alice, "Anniversaire");
|
||||
const before = await openReceivedNote(bob, secretRef);
|
||||
check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)");
|
||||
|
||||
await shareNoteWith(alice, "Anniversaire", BOB);
|
||||
// Bob reopens the application: connecting is what applies what was deposited for
|
||||
// him. He calls nothing — there is no "receive" in this model.
|
||||
const bob2 = await reopen(ctx!, url, bob);
|
||||
const after = await openReceivedNote(bob2, secretRef);
|
||||
check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after);
|
||||
bob.frame = bob2.frame;
|
||||
bob.page = bob2.page;
|
||||
});
|
||||
|
||||
// 3. A note opened for messages: anyone deposits, only its owner reads. Bob addresses
|
||||
// the NOTE — he never names an inbox, and no application should have to.
|
||||
await journey("Bob leaves a message on Alice's note, and only Alice reads it", async () => {
|
||||
await showScope(alice, "public", "Courses"); // her public shelf
|
||||
await openForMessages(alice, "Courses");
|
||||
// Bob has to REOPEN so the address published on the note is visible to his session.
|
||||
const bob2 = await reopen(ctx!, url, bob);
|
||||
await leaveMessage(bob2, publicRef, "j'apporte le café");
|
||||
const mine = await readMessages(alice, "Courses");
|
||||
check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine);
|
||||
bob.frame = bob2.frame;
|
||||
bob.page = bob2.page;
|
||||
});
|
||||
|
||||
// 4. Each actor lists their OWN notes and nothing else — the boundary, seen from
|
||||
// the only place that matters: what the screen shows.
|
||||
await journey("each actor's list holds their own notes, and no one else's", async () => {
|
||||
// POSITIVE CONTROL. Bob writes a public note of his own first — without it his list
|
||||
// is empty whatever the boundary does, and "it does not contain Alice's note" is
|
||||
// true for the wrong reason. The assertion has to be able to fail.
|
||||
await writeNote(bob, "public", "Vélo", "réviser les freins");
|
||||
await showScope(bob, "public", "Vélo");
|
||||
const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
||||
|
||||
// Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see
|
||||
// it" is read off a stale snapshot and holds whatever the boundary does. Writing a
|
||||
// note is the synchronisation point the application offers: `writeNote` awaits the
|
||||
// new entry appearing, so what follows is a render that post-dates Bob's.
|
||||
await writeNote(alice, "public", "Timbres", "en acheter un carnet");
|
||||
const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
||||
|
||||
check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60));
|
||||
check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60));
|
||||
check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60));
|
||||
check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60));
|
||||
});
|
||||
} finally {
|
||||
await ctx?.close().catch(() => {});
|
||||
closeServer();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
|
||||
console.log(
|
||||
`\n══ Application e2e summary: ${results.length - failed} passed, ${failed} failed, ` +
|
||||
`${results.length} total — ${minutes} min ══`,
|
||||
);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
void main();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* DECISIVE real-broker determination: does `doc_subscribe` actually PUSH when a
|
||||
* subscribed document is written?
|
||||
*
|
||||
* This is the reactive-layer coverage whose ABSENCE let a reactivity bug ship: the
|
||||
* app's whole read-model reactivity rests on `subscribeDoc(nuri, cb)` (the polyfill
|
||||
* wrapper over `ng.doc_subscribe`, `src/subscribe.ts`) firing `cb` again on every
|
||||
* commit to the doc. Two pushes are load-bearing in production and were reported as
|
||||
* NOT firing:
|
||||
* (SELF) a session's own `sparqlUpdate` to a doc it subscribes to.
|
||||
* (CROSS) another session writes to a doc the first session subscribes to.
|
||||
*
|
||||
* This runner exercises BOTH against the REAL broker, through the SAME public
|
||||
* surface the app uses — `subscribeDoc` (via the harness's `stateProbe*` bridge,
|
||||
* which passes the raw `AppResponse` straight through the polyfill wrapper),
|
||||
* `docs.docCreate`, and `docs.sparqlUpdate` (`writeTo`). It records EVERY push as a
|
||||
* typed event (`{ typeKey: "State" | "Patch" | "TabInfo" | …, elapsedMs }`) so the
|
||||
* verdict is the ground truth "did the subscription callback fire again", not a
|
||||
* re-read of the document. Each wait is a single event-driven promise+timeout on the
|
||||
* push (NO re-read loop) — a timeout is a DEFINITE "did-not-fire", not a flaky miss.
|
||||
*
|
||||
* Standalone (NOT `bun test`). Run:
|
||||
* bun run e2e/reactivity-doc-subscribe.ts
|
||||
* (or `bun run test:e2e:reactivity` from packages/polyfill)
|
||||
*
|
||||
* It reuses the exact real-broker plumbing of run.ts / broker.ts: the dedicated lib
|
||||
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
|
||||
* the SAME persistent wallet context — a second concurrent verifier session on one
|
||||
* shared wallet (as faithfulReconnect does) — and writes from it.
|
||||
*/
|
||||
|
||||
import type { Frame, Page, BrowserContext } from "playwright";
|
||||
import {
|
||||
buildBundle,
|
||||
serveHarness,
|
||||
ensureWallet,
|
||||
launchWalletContext,
|
||||
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 });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
type Event = { typeKey: string; elapsedMs: number };
|
||||
|
||||
// Call a bridge method inside a given 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decisive wait: resolve TRUE as soon as the probe's recorded push count grows
|
||||
* past `base` (the subscription callback fired again), or FALSE on timeout. This is
|
||||
* a promise+timeout on the PUSH itself — it polls only the in-memory event counter
|
||||
* the `subscribeDoc` callback writes, NEVER re-reads the document. A FALSE here is a
|
||||
* definite non-delivery within the window, not a missed re-read.
|
||||
*/
|
||||
async function waitForPush(frame: Frame, base: number, timeoutMs: number): Promise<boolean> {
|
||||
try {
|
||||
await frame.waitForFunction(
|
||||
(b) => (window as any).__sdk.stateProbeEvents().length > (b as number),
|
||||
base,
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // timed out → the callback did NOT fire again within the window
|
||||
}
|
||||
}
|
||||
|
||||
const seq = (events: Event[]): string =>
|
||||
events.length ? events.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ") : "(none)";
|
||||
|
||||
async function openSession(
|
||||
ctx: BrowserContext,
|
||||
url: string,
|
||||
tag: string,
|
||||
): Promise<{ page: Page; frame: Frame; sessionId: string }> {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(`[iframe error:${tag}]`, e.message));
|
||||
page.on("console", (m) => {
|
||||
const t = m.text();
|
||||
// Surface the polyfill's own "doc_subscribe FIRE" diagnostic (subscribe.ts) if
|
||||
// access logging happens to be on — an independent confirmation of a push.
|
||||
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
|
||||
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
|
||||
});
|
||||
const frame = await setupBrokerPage(page, url);
|
||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||
timeout: 60000,
|
||||
});
|
||||
const info = await sdk<{ session_id: string } | null>(frame, "sessionInfo");
|
||||
const sessionId = info?.session_id ?? "(none)";
|
||||
console.log(`[session:${tag}] connected — session_id=${sessionId}`);
|
||||
return { page, frame, sessionId };
|
||||
}
|
||||
|
||||
const SELF_TIMEOUT_MS = 10000;
|
||||
const CROSS_TIMEOUT_MS = 15000;
|
||||
const STATE_TIMEOUT_MS = 20000;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[reactivity] building SDK page bundle...");
|
||||
buildBundle();
|
||||
console.log("[reactivity] ensuring dedicated lib wallet...");
|
||||
await ensureWallet();
|
||||
const { url, close: closeServer } = await serveHarness();
|
||||
console.log(`[reactivity] harness served at ${url}`);
|
||||
|
||||
let ctx: BrowserContext | null = null;
|
||||
try {
|
||||
ctx = await launchWalletContext();
|
||||
|
||||
// ── Session A (the subscriber for both cases) ────────────────────────────
|
||||
const A = await openSession(ctx, url, "A");
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// CASE 1 — SELF: A subscribes to D, then A itself writes to D.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
console.log("\n── CASE 1: SELF (single session — own write to own subscribed doc) ──");
|
||||
{
|
||||
const doc = await sdk<string>(A.frame, "docCreate");
|
||||
console.log(` [SELF] created doc D = ${doc}`);
|
||||
await sdk(A.frame, "stateProbeSubscribe", doc);
|
||||
|
||||
// Wait for the initial State (the sync barrier). TabInfo may precede it.
|
||||
const gotState = await (async () => {
|
||||
try {
|
||||
await A.frame.waitForFunction(
|
||||
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||
{ timeout: STATE_TIMEOUT_MS },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const afterSubscribe = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
console.log(` [SELF] pushes after subscribe: ${seq(afterSubscribe)}`);
|
||||
record(
|
||||
"SELF: initial State push arrives on subscribe (baseline sanity)",
|
||||
gotState && afterSubscribe.some((e) => e.typeKey === "State"),
|
||||
`sequence=${seq(afterSubscribe)}`,
|
||||
);
|
||||
|
||||
// Now the decisive write: A's OWN sparqlUpdate to D.
|
||||
const preWrite = afterSubscribe.length;
|
||||
console.log(` [SELF] A writes to D (own sparqlUpdate); waiting ≤${SELF_TIMEOUT_MS}ms for a push…`);
|
||||
await sdk(A.frame, "writeTo", doc, "self-1");
|
||||
const fired = await waitForPush(A.frame, preWrite, SELF_TIMEOUT_MS);
|
||||
|
||||
const afterWrite = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
const newEvents = afterWrite.slice(preWrite);
|
||||
console.log(` [SELF] pushes AFTER own write: ${seq(newEvents)}`);
|
||||
console.log(` [SELF] VERDICT: callback ${fired ? "FIRED" : "did NOT fire"} within ${SELF_TIMEOUT_MS}ms`);
|
||||
record(
|
||||
`SELF: subscription callback fires on the session's OWN write (≤${SELF_TIMEOUT_MS}ms)`,
|
||||
fired,
|
||||
`newPushes=${seq(newEvents)}`,
|
||||
);
|
||||
await sdk(A.frame, "stateProbeStop");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// CASE 2 — CROSS-SESSION: A subscribes to D2; a SECOND session B (same shared
|
||||
// wallet, own concurrent verifier session) writes to D2.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
console.log("\n── CASE 2: CROSS-SESSION (session B writes to a doc session A subscribes to) ──");
|
||||
let B: { page: Page; frame: Frame; sessionId: string } | null = null;
|
||||
try {
|
||||
B = await openSession(ctx, url, "B");
|
||||
} catch (e: any) {
|
||||
console.log(` [CROSS] COULD-NOT-TEST: second concurrent session on the shared wallet failed to open: ${String(e?.message ?? e)}`);
|
||||
record(
|
||||
"CROSS: second concurrent session opened on the shared wallet",
|
||||
false,
|
||||
`open failed: ${String(e?.message ?? e)} — see Festipod multibrowser harness as the alternative venue`,
|
||||
);
|
||||
}
|
||||
|
||||
if (B) {
|
||||
// NB: `session_id` is a PER-PAGE local verifier counter (each fresh iframe
|
||||
// numbers its first session "1"), so it is NOT a global identifier and cannot
|
||||
// be used to prove distinctness. The REAL proof that A and B are two separate
|
||||
// verifier sessions is behavioural: B's write reaches A only after a broker
|
||||
// round-trip (a delayed Patch), not as an instant same-session echo.
|
||||
console.log(
|
||||
` [CROSS] both pages connected — A.session=${A.sessionId} B.session=${B.sessionId} (per-page local counter; distinctness shown by the cross-broker propagation below)`,
|
||||
);
|
||||
record(
|
||||
"CROSS: a second concurrent page/session is open on the same shared wallet",
|
||||
true,
|
||||
`A=${A.sessionId} B=${B.sessionId} (session_id is a per-page counter, not a global id)`,
|
||||
);
|
||||
|
||||
// A creates D2 and subscribes.
|
||||
const doc2 = await sdk<string>(A.frame, "docCreate");
|
||||
console.log(` [CROSS] A created doc D2 = ${doc2}`);
|
||||
await sdk(A.frame, "stateProbeSubscribe", doc2);
|
||||
const gotState2 = await (async () => {
|
||||
try {
|
||||
await A.frame.waitForFunction(
|
||||
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||
{ timeout: STATE_TIMEOUT_MS },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const afterSub2 = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
console.log(` [CROSS] A pushes after subscribe: ${seq(afterSub2)}`);
|
||||
record(
|
||||
"CROSS: A receives its initial State on D2 (baseline sanity)",
|
||||
gotState2 && afterSub2.some((e) => e.typeKey === "State"),
|
||||
`sequence=${seq(afterSub2)}`,
|
||||
);
|
||||
|
||||
// B writes to D2. Capture a write failure (e.g. RepoNotFound) explicitly —
|
||||
// it would mean B cannot reach A's doc, which is itself a determination.
|
||||
const preCross = afterSub2.length;
|
||||
let writeThrew: string | null = null;
|
||||
// Cross-session writes to a doc created by ANOTHER session can be slow: B must
|
||||
// sync/open D2's repo before it can commit. Time it separately so the push
|
||||
// latency is reported relative to when B's write actually LANDED, not to
|
||||
// subscribe time.
|
||||
console.log(` [CROSS] B writes to D2 from its own session…`);
|
||||
const tWriteStart = Date.now();
|
||||
try {
|
||||
await sdk(B.frame, "writeTo", doc2, "cross-1");
|
||||
} catch (e: any) {
|
||||
writeThrew = String(e?.message ?? e);
|
||||
console.log(` [CROSS] B's write THREW: ${writeThrew}`);
|
||||
}
|
||||
const writeMs = Date.now() - tWriteStart;
|
||||
record("CROSS: session B's write to D2 did not throw", writeThrew === null, writeThrew ? writeThrew : `landed in ${writeMs}ms`);
|
||||
|
||||
console.log(` [CROSS] B's write returned in ${writeMs}ms; now waiting ≤${CROSS_TIMEOUT_MS}ms for A's push…`);
|
||||
const tWaitStart = Date.now();
|
||||
const crossFired = writeThrew ? false : await waitForPush(A.frame, preCross, CROSS_TIMEOUT_MS);
|
||||
const pushAfterWriteMs = Date.now() - tWaitStart;
|
||||
const afterCross = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
const crossNew = afterCross.slice(preCross);
|
||||
console.log(` [CROSS] A pushes AFTER B's write: ${seq(crossNew)}`);
|
||||
console.log(
|
||||
` [CROSS] VERDICT: A's callback ${crossFired ? `FIRED (${pushAfterWriteMs}ms after B's write landed)` : "did NOT fire"} within ${CROSS_TIMEOUT_MS}ms${writeThrew ? " (B's write threw first)" : ""}`,
|
||||
);
|
||||
record(
|
||||
`CROSS: A's subscription callback fires on B's write (≤${CROSS_TIMEOUT_MS}ms after B's write landed)`,
|
||||
crossFired,
|
||||
`newPushes=${seq(crossNew)} (B write took ${writeMs}ms; push ${crossFired ? pushAfterWriteMs + "ms after" : "not seen"})${writeThrew ? ` — B write threw: ${writeThrew}` : ""}`,
|
||||
);
|
||||
await sdk(A.frame, "stateProbeStop");
|
||||
}
|
||||
} finally {
|
||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||
closeServer();
|
||||
}
|
||||
|
||||
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
|
||||
console.log("\n══ doc_subscribe delivery determination ══");
|
||||
for (const r of results) console.log(` [${r.ok ? "PASS" : "FAIL"}] ${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||||
const self = results.find((r) => r.name.startsWith("SELF: subscription callback fires"));
|
||||
const cross = results.find((r) => r.name.startsWith("CROSS: A's subscription callback fires"));
|
||||
console.log("\n SELF →", self ? (self.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||
console.log(" CROSS →", cross ? (cross.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[reactivity] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
|
||||
* shim's first account resolution/provision.
|
||||
*
|
||||
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
|
||||
* private-store repo is already in the verifier's `self.repos`), this mints a
|
||||
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
|
||||
* very first session. It then, in order:
|
||||
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
|
||||
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
|
||||
* yet open) rather than a silent 0 rows;
|
||||
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
|
||||
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
|
||||
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
|
||||
* the SAME docs (real persistence in the shim).
|
||||
*
|
||||
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
|
||||
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
|
||||
* succeed because ensureAccount opens the anchor repo before read/write.
|
||||
*
|
||||
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { Frame, Page, BrowserContext } from "playwright";
|
||||
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
|
||||
|
||||
type Check = { name: string; ok: boolean; detail?: string };
|
||||
const results: Check[] = [];
|
||||
function check(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[repro] building SDK page bundle...");
|
||||
buildBundle();
|
||||
const { url, close: closeServer } = await serveHarness();
|
||||
console.log(`[repro] harness served at ${url}`);
|
||||
|
||||
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
||||
let ctx: BrowserContext | null = null;
|
||||
let dir: string | null = null;
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
const fresh = await createFreshWalletContext();
|
||||
ctx = fresh.ctx;
|
||||
dir = fresh.dir;
|
||||
console.log(`[repro] fresh wallet: ${fresh.name}`);
|
||||
|
||||
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("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
||||
const frame = await setupBrokerPage(page, url);
|
||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||
timeout: 60000,
|
||||
});
|
||||
console.log("[repro] connected. Driving the cold-start shim resolution...");
|
||||
|
||||
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
|
||||
// private-store repo is not yet open in this fresh session.
|
||||
const probe = await sdk<any>(frame, "shimAnchorProbe");
|
||||
console.log(
|
||||
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
|
||||
);
|
||||
|
||||
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
|
||||
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
|
||||
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
|
||||
check(
|
||||
"fresh wallet: ensureAccount provisions the account without throwing",
|
||||
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
|
||||
ensured.threw
|
||||
? `THREW: ${ensured.error}`
|
||||
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}…`,
|
||||
);
|
||||
|
||||
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
|
||||
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
|
||||
check(
|
||||
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
|
||||
!verified.threw &&
|
||||
verified.docPublic === ensured.docPublic &&
|
||||
verified.docProtected === ensured.docProtected &&
|
||||
verified.docPrivate === ensured.docPrivate,
|
||||
verified.threw
|
||||
? `THREW: ${verified.error}`
|
||||
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
|
||||
);
|
||||
} finally {
|
||||
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
closeServer();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - passed;
|
||||
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
|
||||
if (failed > 0) {
|
||||
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[repro] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* 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 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const BATCH_BUDGET_MS = 45 * 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> {
|
||||
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)}`);
|
||||
|
||||
// ── 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}`,
|
||||
);
|
||||
});
|
||||
await step("the gate stays away when the identity is already known", async () => {
|
||||
const r = await sdk<any>(frame, "accessGateReturningVisit", "erin");
|
||||
check("no barrier for a returning user", 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)`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── read-model ──────────────────────────────────────────────────────────
|
||||
console.log("\n── read-model ──");
|
||||
await step("readUnion 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 `readUnion` conflated them (fixed 2026-08-10).
|
||||
const r = await sdk<any>(frame, "readUnionOverDocs", 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("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
||||
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
||||
// 2 documents, the last carrying two subjects → 3 entries.
|
||||
check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
|
||||
});
|
||||
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");
|
||||
});
|
||||
// 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)}…`);
|
||||
});
|
||||
|
||||
// ── 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(
|
||||
"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 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
|
||||
? ((coldSyncMs = Math.max(coldSyncMs, syncMs)),
|
||||
`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;
|
||||
const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1);
|
||||
console.log(
|
||||
`\n══ SDK 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);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
Reference in New Issue
Block a user