diff --git a/.project/concepts/app-contract/contract_polyfill-surface.md b/.project/concepts/app-contract/contract_polyfill-surface.md index ade9bc7..6168935 100644 --- a/.project/concepts/app-contract/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/contract_polyfill-surface.md @@ -64,7 +64,7 @@ export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: Doc // ── low-level document / SPARQL primitives ─────────────────────────────── export const docs: { - docCreate(sessionId: string, crdt?: string, cls?: string, dest?: string, store?: unknown): Promise; + docCreate(sessionId: string, crdt: string, cls: string, dest: string, store?: unknown): Promise; sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise; sparqlUpdate(sessionId: string, query: string, anchor?: NuriLike, label?: string): Promise; }; diff --git a/.project/concepts/sign-in/knowledge_settling-is-not-connecting.md b/.project/concepts/sign-in/knowledge_settling-is-not-connecting.md index 6b38602..ba72aaf 100644 --- a/.project/concepts/sign-in/knowledge_settling-is-not-connecting.md +++ b/.project/concepts/sign-in/knowledge_settling-is-not-connecting.md @@ -14,13 +14,15 @@ They must stay apart, and the reason is not tidiness. ## Why they cannot be one call -`init()` hands the page over synchronously when the page is top-level, so nothing placed after it runs. The `barrier` must therefore appear **before** `init()`. +`init()` starts the broker redirect as its first statement when the page is top-level, so anything the barrier needs to show must already be in place. The `barrier` must therefore appear **before** `init()`. But an identity cannot connect before `init()` either: connecting reaches the application's session thunk, which only `init()`'s own callback resolves. Await the whole of sign-in before `init()` and it deadlocks; call it after and the `barrier` never appears. The way out is that only *half* of it has that dependency. Settling is awaited by this package's `init()` wrapper before it delegates; connecting stays in the published call, awaited where a session exists. -**The ordering invariant is carried by the composition, not by a documented call order** — which is why an application must call this package's `init`, not the one it injected. A note telling callers to order two calls correctly is not a mechanism. +**The invariant is carried by the composition from `init()` onwards** — which is why an application must call this package's `init`, not the one it injected: calling it settles the identity whether or not the published call has run. + +It does **not** cover calling the published sign-in strictly first and awaiting it: the connection work it adds waits for a session only `init()` resolves, so it deadlocks in silence. Half an invariant carried by a mechanism, half still owed to the caller — and the half still owed fails silently, which is the worst kind. ## The failure this cost diff --git a/packages/polyfill/e2e/broker.ts b/packages/polyfill/e2e/broker.ts index 170701d..555a118 100644 --- a/packages/polyfill/e2e/broker.ts +++ b/packages/polyfill/e2e/broker.ts @@ -16,15 +16,44 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import type { Socket } from "node:net"; +import { + CONTEXT_ACTION_MS, + CONTEXT_NAVIGATION_MS, + browserLost, + closeQuietly, + within, +} from "./deadline"; +import { isAlive } from "./run-lock"; 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"); +/** + * Marks that a batch has already taken the physical user held in this profile. + * + * Named for the only question it is ever asked. It is WRITTEN when a wallet is created and + * READ in exactly one place — `ensureWallet`, to decide "discard this profile and mint a + * fresh user". It never gated anything on readiness, and the previous name (`.wallet-ready`) + * said it did: it cost a wrong diagnosis on 2026-08-11, where its presence was read as + * "the wallet is good to use" when it means the opposite — this user belongs to a batch + * that is over. `user` rather than `wallet` because what a batch consumes is an identity; + * the wallet is only its container (see the note on ensureWallet below). + */ +const USER_CONSUMED_MARKER = path.join(PROFILE_DIR, ".user-consumed"); export const WALLET_NAME = "ng-eventually-e2e"; export const WALLET_PASSWORD = "ng-eventually-e2e"; +/** `bun build` is a local bundle; a minute is already ten times what it takes. */ +const BUILD_MS = 60_000; +/** Launching a browser is local too — 30s is Playwright's own default, doubled. */ +const LAUNCH_MS = 60_000; +/** Opening a page in a live browser is instant; a minute means the browser is not answering. */ +const NEW_PAGE_MS = 60_000; +/** The whole wallet export measures ~7s against the real broker; two minutes is a hang. */ +const EXPORT_MS = 120_000; + const ENTRY = path.resolve(__dirname, "polyfill-entry.ts"); const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js"); @@ -47,13 +76,48 @@ export function buildBundle(): void { execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, { stdio: "pipe", cwd: path.resolve(__dirname, ".."), + timeout: BUILD_MS, + }); +} + +/** + * Serve `page` and `routes` on an ephemeral port, and hand back a close that CLOSES. + * + * `server.close()` alone stops the listener and then waits for every keep-alive connection + * to drain on its own — a browser that is still attached keeps the server half-alive long + * after the harness believes it gone. These suites close a server while a browser is still + * pointed at it (the wallet export does exactly that), so the sockets are tracked and + * destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the + * suite instead of on the connection nobody hung up. + */ +export function serveOnEphemeralPort( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise<{ url: string; close: () => void }> { + const server = http.createServer(handler); + const open = new Set(); + server.on("connection", (socket) => { + open.add(socket); + socket.on("close", () => open.delete(socket)); + }); + 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(); + for (const socket of open) socket.destroy(); + open.clear(); + }, + }); + }); }); } export function serveHarness(): Promise<{ url: string; close: () => void }> { const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); const html = `ng-eventually polyfill e2e
`; - const server = http.createServer((req, res) => { + return serveOnEphemeralPort((req, res) => { if (req.url === "/polyfill-entry.js") { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); @@ -62,12 +126,73 @@ export function serveHarness(): Promise<{ url: string; close: () => void }> { 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() }); - }); - }); +} + +// ── every browser this harness opens, launched and watched the same way ─────── + +/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */ +const closingOnPurpose = new WeakSet(); + +/** + * Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied + * and its disappearance turned into an immediate, named failure. + * + * The watch is the load-bearing part. VERIFIED 2026-08-11: this suite's browser can exit + * mid-run — the devtools pipe between the runner and Chromium is terminated and Chromium + * shuts down (exitCode=0) — and Playwright does NOT reject the calls already waiting on it. + * A bounded wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, + * and `context.close()` in a `finally`) waits for ever. That is how a 60-second failure + * became three runs killed at 50 and 68 minutes having printed nothing. + * + * So the context's own `close` event is listened to, and anything it was not asked to do + * is declared a loss once, loudly, for every wait at once. + */ +async function launchWatchedContext(label: string, dir: string): Promise { + const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () => + chromium.launchPersistentContext(dir, { + headless: true, + executablePath: resolveChromePath(), + args: LAUNCH_ARGS, + timeout: LAUNCH_MS, + }), + ); + ctx.setDefaultTimeout(CONTEXT_ACTION_MS); + ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS); + const gone = (how: string) => { + if (closingOnPurpose.has(ctx)) return; + browserLost( + `the ${label} browser went away mid-run — ${how}. Every wait on it is now ` + + "unanswerable, so the run stops here instead of waiting on a browser that " + + "no longer exists", + ); + }; + // Both signals, and NEITHER of them covers the loss that hurts most — which is the whole + // reason the deadlines above are not optional. + // + // VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the + // failure this harness actually suffers — Chromium logging "Connection terminated while + // reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its + // client never learns the pipe is gone, so every call already in flight simply waits, and + // every call after it waits too. That is why a browser dying used to cost an hour of + // silence, and why no event-based guard can be the protection here: only a deadline can. + // + // They are wired anyway because they DO catch the losses they can see (a context closed by + // something nobody asked), and those are free to catch immediately rather than at the end + // of a bound. + ctx.on("close", () => gone("its context closed and nobody asked it to")); + ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped")); + return ctx; +} + +/** Close a context we own, bounded, without its `close` event being read as a loss. */ +export async function closeContext(label: string, ctx: BrowserContext): Promise { + closingOnPurpose.add(ctx); + await closeQuietly(`the ${label} context`, () => ctx.close()); +} + +/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */ +export function newPage(label: string, ctx: BrowserContext): Promise { + return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage()); } /** @@ -94,23 +219,80 @@ export function serveHarness(): Promise<{ url: string; close: () => void }> { * CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and * the absence of an account fork across it). */ +/** + * Make sure no browser is still holding `dir`, and kill the one that is. + * + * ── Why a run has to do this ───────────────────────────────────────────────── + * A run that fails or is killed leaves its Chromium ALIVE — `BrowserContext.close()` in the + * teardown gives up after its bound (and a `kill -9` on the runner never gets there at all). + * That orphan keeps the shared profile open, and the NEXT run then deletes the directory + * under it and launches a second Chromium on the same path. Chromium's process singleton + * settles that argument by having the newcomer hand over and quit — which the runner sees + * as its devtools pipe dying moments after launch (VERIFIED 2026-08-11: "Connection + * terminated while reading from pipe" 170 ms after ``). + * + * So one failure poisons every run after it, each faster than the last, and none of them is + * about the thing under test. Reclaiming the profile is what stops the cascade. + * + * Chromium names the holder itself: `SingletonLock` is a symlink to `-`. And the + * run lock has already established that no LEGITIMATE run is alive — so whatever holds this + * profile is debris, and killing it is safe. + */ +async function reclaimProfile(dir: string): Promise { + let target: string; + try { + target = fs.readlinkSync(path.join(dir, "SingletonLock")); + } catch { + return; // no lock, nothing holding it + } + const pid = Number(target.slice(target.lastIndexOf("-") + 1)); + if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid)) return; + + console.warn( + `[e2e] a browser from an earlier run (pid ${pid}) still holds ${path.basename(dir)} — ` + + "killing it, or this run's own browser would be refused the profile and quit", + ); + try { + process.kill(pid, "SIGKILL"); + } catch { + return; // gone between the check and the signal + } + const deadline = Date.now() + 10_000; + while (isAlive(pid) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 200)); + } + if (isAlive(pid)) { + throw new Error( + `[e2e] could not reclaim ${dir}: pid ${pid} survived SIGKILL. Nothing this run does ` + + "next would be measuring the code under test — stop and clear it by hand.", + ); + } +} + export async function ensureWallet(): Promise { - if (fs.existsSync(WALLET_READY_MARKER)) { - const age = Date.now() - fs.statSync(WALLET_READY_MARKER).mtimeMs; + await reclaimProfile(PROFILE_DIR); + // Discard on the PROFILE's existence, not on the marker's. + // + // Keyed on the marker, a profile left behind by a batch that died BEFORE writing it was + // silently REUSED — precisely the opposite of the per-batch rule below, and a profile + // half-way through wallet creation makes the nextgraph.eu flow fail on a screen the code + // does not expect ("element was detached from the DOM"). Worse, it is self-perpetuating: + // the failed run writes no marker either, so every later run inherits the same wreck. + // Observed 2026-08-11. The marker now only says HOW OLD the discarded user was. + if (fs.existsSync(PROFILE_DIR)) { + const age = fs.existsSync(USER_CONSUMED_MARKER) + ? `${Math.round((Date.now() - fs.statSync(USER_CONSUMED_MARKER).mtimeMs) / 60000)} min old` + : "left by a batch that did not finish"; console.log( - `[e2e] discarding the previous batch's wallet (${Math.round(age / 60000)} min old) — ` + + `[e2e] discarding the previous batch's wallet (${age}) — ` + "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()); + const ctx = await launchWatchedContext("wallet-creation", PROFILE_DIR); + const page = ctx.pages()[0] || (await newPage("the wallet creation flow", ctx)); page.on("pageerror", () => {}); try { await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 }); @@ -150,22 +332,18 @@ export async function ensureWallet(): Promise { await page.waitForTimeout(10000); console.log("[e2e] dedicated lib wallet created + bootstrapped"); } finally { - await ctx.close(); + await closeContext("wallet-creation", ctx); } - fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString()); + fs.writeFileSync(USER_CONSUMED_MARKER, new Date().toISOString()); } -export async function launchWalletContext(): Promise { - return chromium.launchPersistentContext(PROFILE_DIR, { - headless: true, - executablePath: resolveChromePath(), - args: LAUNCH_ARGS, - }); +export async function launchWalletContext(label = "wallet"): Promise { + return launchWatchedContext(label, PROFILE_DIR); } /** * 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. + * context, without a `.user-consumed` 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. @@ -182,12 +360,8 @@ export async function createFreshWalletContext(): Promise<{ }> { 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()); + const ctx = await launchWatchedContext("fresh-wallet", dir); + const page = ctx.pages()[0] || (await newPage("the fresh wallet creation flow", ctx)); page.on("pageerror", () => {}); await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 }); const createButton = page.getByText("Create Wallet", { exact: true }); @@ -239,22 +413,75 @@ export async function createFreshWalletContext(): Promise<{ */ 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, - }); + const ctx = await launchWatchedContext("clean-profile", dir); return { ctx, dir }; } +/** + * Materialize THIS batch's wallet as a `.ngw` file at `ngwPath`, and return its size. + * + * A wallet's bytes exist only inside the broker iframe — `ng.wallet_get_file()` is an RPC + * to the wallet the broker holds — so nothing in Node can produce one. The harness page + * already exposes the call (`polyfill-entry.ts`, `exportWalletFile`), and `run.ts` uses it + * for the clean-profile cold-start; this wraps the same round-trip for callers that hold + * no harness frame of their own. + * + * Why the APPLICATIVE suite needs it: the access gate HANDS A WALLET FILE OUT. Serving a + * placeholder there would make the download step a decoration — the file has to be a real + * wallet, or importing it cannot let anybody in. + */ +export async function exportWalletNgw(ctx: BrowserContext, ngwPath: string): Promise { + buildBundle(); + const { url, close } = await serveHarness(); + const page = await newPage("the wallet export", ctx); + page.on("pageerror", () => {}); + try { + const frame = await setupBrokerPage(page, url); + await frame.waitForFunction( + () => (window as unknown as { __sdk?: unknown }).__sdk !== undefined, + { timeout: 30000 }, + ); + await frame.waitForFunction( + () => (window as unknown as { __sdk: { status(): string } }).__sdk.status() === "connected", + { timeout: 60000 }, + ); + // `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one + // of the two ways this harness used to hang for ever. The whole export measures ~7s. + const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () => + frame.evaluate(() => + ( + window as unknown as { + __sdk: { exportWalletFile(): Promise<{ walletName: string; b64: string; len: number }> }; + } + ).__sdk.exportWalletFile(), + ), + ); + fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64")); + return exported.len; + } finally { + await closeQuietly("the wallet export page", () => page.close()); + close(); + } +} + /** * 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). + * + * The password is a PARAMETER, defaulting to the batch wallet's. The applicative suite + * reads it off the access gate's own screen and passes it here — which is the only way a + * test can tell that what the barrier DISPLAYS is what actually opens the file. Hard-coding + * it here would make that step untestable: the import would succeed on a barrier showing + * anything at all, including nothing. */ -export async function importWalletViaFile(page: Page, ngwPath: string): Promise { +export async function importWalletViaFile( + page: Page, + ngwPath: string, + password: string = WALLET_PASSWORD, +): Promise { 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); @@ -262,7 +489,7 @@ export async function importWalletViaFile(page: Page, ngwPath: string): Promise< 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.fill(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(() => {}); @@ -277,7 +504,22 @@ export async function importWalletViaFile(page: Page, ngwPath: string): Promise< export async function setupBrokerPage(page: Page, appUrl: string): Promise { const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`; await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" }); + return completeBrokerLogin(page, appUrl); +} +/** + * The half of {@link setupBrokerPage} that does NOT navigate: unlock if a login is shown, + * then wait for the application's iframe and return it. + * + * Split out because there are two ways to arrive at the broker, and only one of them is + * the suite's. A test signs the round-trip off itself (`setupBrokerPage`); an APPLICATION + * hands the page over on its own, inside `init()`, once the identity is settled — and a + * journey that walks a first-time user through the barrier has to let it, because that + * hand-over IS what it is checking. Calling `setupBrokerPage` there would re-navigate and + * throw away the URL the application had just built, `?ng-id=` included — i.e. it would + * quietly substitute the suite's path for the one under test. + */ +export async function completeBrokerLogin(page: Page, appUrl: string): Promise { const loginButton = page.getByText("Login", { exact: true }); if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) { await loginButton.click(); diff --git a/packages/polyfill/e2e/deadline.ts b/packages/polyfill/e2e/deadline.ts new file mode 100644 index 0000000..8852f04 --- /dev/null +++ b/packages/polyfill/e2e/deadline.ts @@ -0,0 +1,209 @@ +/** + * Deadlines for the e2e harnesses — so a wait that cannot end FAILS, named, instead of + * hanging. + * + * ── Why this module exists ─────────────────────────────────────────────────── + * A harness that hangs is worse than one that fails. A failure names a suspect and costs a + * minute; a hang costs an hour and leaves every measurement of the session undecidable — + * was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites + * lean on have NO bound at all: `frame.evaluate()` (which is what every `sdk(...)` call in + * `run.ts` is) and `context.newPage()`. Playwright applies no timeout to either. + * + * And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away + * mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies + * INSIDE its own cleanup, after its last journey, without ever printing its summary or its + * failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness + * was killed for, three times. + * + * So: every wait that can block gets a deadline, and on expiry an error that says WHAT it + * was waiting for and WHERE — the chain of journeys and steps it sits inside (see + * {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing + * from "which wait" to "which of these thirty-two". + * + * ── Bounds are generous on purpose ─────────────────────────────────────────── + * The numbers are sized from OBSERVED healthy timings with a wide margin (see each + * caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a + * bound that fires on a slow broker manufactures exactly the false diagnosis it exists to + * prevent. + */ + +/** Thrown when a bounded wait outlives its deadline. */ +export class DeadlineExceeded extends Error { + constructor(what: string, ms: number, where: string) { + super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`); + this.name = "DeadlineExceeded"; + } +} + +/** + * Thrown at every wait in flight when the browser they all depend on has gone away. + * + * Without it, a dead browser is discovered one 60-second timeout at a time — or never, on + * the waits Playwright does not bound. The suite has nothing left to measure at that + * point, so the useful thing is to say so once, immediately, and name the loss. + */ +export class BrowserGone extends Error { + constructor(reason: string, what: string, where: string) { + super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`); + this.name = "BrowserGone"; + } +} + +function fmtMs(ms: number): string { + return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`; +} + +interface Pending { + what: string; + where: string; + ms: number; + startedAt: number; + abandon: (e: Error) => void; +} + +/** Everything currently being waited on, so a loss can name every casualty at once. */ +const pending = new Set(); + +/** Set once the run has lost the thing every wait depends on. */ +let lost: string | null = null; + +/** + * Where a wait sits, as the chain of waits enclosing it — `journey X › alice to sign in`. + * + * Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames + * across `await` boundaries: measured 2026-08-11, a `within` called from an async function + * reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site + * is silently wrong exactly when it is needed. The enclosing chain is better anyway — a + * reader wants "which journey, which step" far more than a line number, and journeys and + * steps are themselves bounded waits, so the chain is already there to be read. + * + * These suites are strictly sequential, which is what makes "everything else in flight" the + * same thing as "everything enclosing this". A concurrent harness would need real context + * propagation. + */ +function enclosing(): string { + const chain = [...pending].map((p) => p.what); + return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" › ")}`; +} + +/** + * Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared — + * reject with an error naming what was being waited for and where. + * + * The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its + * eventual rejection is absorbed instead, because a race loser surfacing as an unhandled + * rejection would crash the process minutes after the real failure was already reported. + */ +export function within(what: string, ms: number, task: () => Promise): Promise { + if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing())); + return bounded(what, ms, enclosing(), task); +} + +/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */ +function bounded(what: string, ms: number, where: string, task: () => Promise): Promise { + let timer: ReturnType | undefined; + let entry!: Pending; + const interrupted = new Promise((_, reject) => { + entry = { what, where, ms, startedAt: Date.now(), abandon: reject }; + timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms); + }); + pending.add(entry); + const running = task(); + running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection + return Promise.race([running, interrupted]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + pending.delete(entry); + }); +} + +/** + * Declare that the browser every wait depends on has gone, and abandon them all now. + * + * Idempotent, and one-way: once a run has lost its browser there is nothing further to + * measure, so later waits are refused rather than left to time out one by one. + */ +export function browserLost(reason: string): void { + if (lost !== null) return; + lost = reason; + console.error(`\n[e2e] ${reason}`); + if (pending.size > 0) { + console.error(` ${pending.size} wait(s) were in flight and are abandoned:`); + for (const p of pending) console.error(` - ${p.what} [${p.where}]`); + } + for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where)); +} + +/** Teardown bound: a close that has not returned in 30s is not going to. */ +export const CLOSE_MS = 30_000; + +/** + * Close a page/context/server under a deadline, reporting rather than throwing. + * + * Teardown is where a bound matters most and an exception matters least: the verdict is + * already decided, so a close that never returns must not be what the run dies of. This is + * the exact shape of the observed hang — `BrowserContext.close()` on a browser that had + * already exited, inside a `finally`, swallowing the summary that was on its way out. + * + * Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when + * closing matters most. Skipping it there would leave the Chromium processes of a failed + * run alive, and the next run would inherit them. + */ +export async function closeQuietly(what: string, close: () => Promise): Promise { + try { + await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => { + await close(); + }); + } catch (e) { + console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`); + } +} + +/** + * Arm the suite's own wall clock. On expiry, name every wait still in flight and exit. + * + * The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It + * reports before it dies, because "the run was killed" is the uninformative message that + * cost the hours this module exists to stop spending. + * + * `unref`ed, so a healthy run is never held open by its own watchdog. + */ +export function armSuiteDeadline(suite: string, ms: number): void { + const startedAt = Date.now(); + const timer = setTimeout(() => { + console.error( + `\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` + + " This is a HANG, not a verdict.", + ); + if (pending.size === 0) { + console.error( + " Nothing was inside a bounded wait, so the block is in unbounded code: " + + "wrap the step it stopped at with `within(...)`.", + ); + } else { + console.error(` Waits still in flight (${pending.size}):`); + for (const p of pending) { + console.error( + ` - ${p.what} — ${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`, + ); + } + } + console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`); + process.exit(1); + }, ms); + timer.unref?.(); +} + +/** + * Playwright's per-context defaults, set explicitly so the bound on every locator action and + * navigation is a decision in this file rather than a library default nobody looked up. + * + * The value is Playwright's own 30s, deliberately: raising it to 120s was tried on + * 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also + * how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can + * re-render under a click ("element was detached from the DOM, retrying"), and at 120s that + * flake took two minutes to surface instead of thirty seconds. Every action and navigation + * here already had a bound; the waits that had NONE are the ones this module wraps + * (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout. + */ +export const CONTEXT_ACTION_MS = 30_000; +export const CONTEXT_NAVIGATION_MS = 30_000; diff --git a/packages/polyfill/e2e/notebook.ts b/packages/polyfill/e2e/notebook.ts index 5452a28..429c3cf 100644 --- a/packages/polyfill/e2e/notebook.ts +++ b/packages/polyfill/e2e/notebook.ts @@ -30,15 +30,49 @@ 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 os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { ensureWallet, launchWalletContext, setupBrokerPage } from "./broker"; +import { + PROFILE_DIR, + WALLET_PASSWORD, + closeContext, + completeBrokerLogin, + ensureWallet, + exportWalletNgw, + importWalletViaFile, + launchCleanProfileContext, + launchWalletContext, + newPage, + serveOnEphemeralPort, + setupBrokerPage, +} from "./broker"; +import { armSuiteDeadline, closeQuietly, within } from "./deadline"; +import { acquireRunLock } from "./run-lock"; 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"); +/** Where the application configures its shared wallet (`examples/notebook/app.ts`). */ +const WALLET_PATH = "/shared-wallet.ngw"; + +// ── bounds ────────────────────────────────────────────────────────────────── + +/** + * The batch's wall clock. A healthy run measures ~1.5 min; the longest single wait in it is + * the newcomer's cold first render, bounded at 3 min. Fifteen is an order of magnitude of + * room and still an answer inside a coffee break — which is the point, since what this + * replaces is a run that was killed by hand at 68 minutes having printed nothing. + */ +const SUITE_DEADLINE_MS = 15 * 60 * 1000; +/** + * One journey. The longest (the newcomer's) does a wallet download, an import into a cold + * profile, a broker round-trip and a first render — measured under 2 min, bounded at 6. + */ +const JOURNEY_MS = 6 * 60 * 1000; +/** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */ +const SIGN_IN_MS = 3 * 60 * 1000; // ── reporting ─────────────────────────────────────────────────────────────── @@ -48,10 +82,17 @@ function check(name: string, ok: boolean, detail?: string): void { results.push({ name, ok, detail }); console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`); } +/** + * A journey, under its own deadline. + * + * The bound is what makes the catch honest: catching everything and recording a FAIL is + * right for a journey that fails, but a journey that never RETURNS is caught by nothing — + * and that is what three killed runs looked like from the outside. + */ async function journey(name: string, fn: () => Promise): Promise { console.log(`\n── ${name} ──`); try { - await fn(); + await within(`the journey "${name}"`, JOURNEY_MS, fn); } catch (e: any) { check(name, false, "threw: " + String(e?.message ?? e)); } @@ -67,24 +108,54 @@ function buildApp(): void { }); } -function serveApp(): Promise<{ url: string; close: () => void }> { +/** + * Serve the application the way a deployment would — including the two things the access + * gate hands a first-time user, which nothing served before. + * + * The **wallet file**: `app.ts` configures `fileUrl: "/shared-wallet.ngw"`, and no such + * file exists in the repository (nor should one — a wallet is never committed). So the + * deployment supplies it, and here that is this server, from bytes exported at test time. + * + * The **password**: `app.ts` reads it from `__NOTEBOOK_WALLET_PASSWORD__`, resolved "at + * its own build" as `SharedWalletConfig` requires — the library reads no environment. The + * inline script below is that resolution; without it the barrier displays an empty + * password and no import can succeed. + * + * And an unknown path now 404s instead of returning the page. That is not tidiness: the + * catch-all made `/shared-wallet.ngw` answer 200 with the application's own HTML, so a + * download of a file NOBODY served looked like a perfectly good download. The check that + * the link resolves could not have failed. + */ +function serveApp(walletFile: Buffer, walletPassword: string): 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")) { + const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8"); + const moduleTag = ''; + if (!page.includes(moduleTag)) { + throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`); + } + const html = page.replace( + moduleTag, + `\n ${moduleTag}`, + ); + return serveOnEphemeralPort((req, res) => { + const route = (req.url ?? "/").split("?")[0]; + if (route === "/app.js") { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); res.end(bundle); - } else { + } else if (route === WALLET_PATH) { + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": 'attachment; filename="shared-wallet.ngw"', + }); + res.end(walletFile); + } else if (route === "/" || route === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); + } else { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("not found"); } }); - 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 ───────────────────────── @@ -101,17 +172,19 @@ interface Actor { } async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise { - 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()); + return within(`${id} to sign in`, SIGN_IN_MS, async () => { + const page = await newPage(id, ctx); + 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 }; }); - // `?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 ─────────────────── @@ -206,28 +279,62 @@ async function readMessages(a: Actor, title: string): Promise { /** 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 { - await a.page.close().catch(() => {}); + await closeQuietly(`${a.id}'s previous page`, () => a.page.close()); return signIn(ctx, appUrl, a.id); } // ── the journeys ──────────────────────────────────────────────────────────── async function main(): Promise { + // Before anything touches the shared profile: this batch is about to DELETE it (see + // `ensureWallet`), so a second run alive right now would be destroyed by this one. + acquireRunLock("the applicative suite (e2e/notebook.ts)", PROFILE_DIR); + armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS); 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}`; + // The wallet the barrier hands out, and the newcomer's download of it. Both under a + // temp dir, removed at the end: a wallet file is an identity, and one must never be + // committed — `*.ngw` is gitignored besides, which is the belt to this brace. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-")); + const sharedWalletFile = path.join(tmpDir, "shared-wallet.ngw"); + let ctx: BrowserContext | null = null; + let closeServer: (() => void) | null = null; const startedAt = Date.now(); try { - ctx = await launchWalletContext(); + // THIS batch's wallet, as bytes. It has to be the very one the other actors live in — + // one shared wallet hosts every identity — and its bytes exist only inside the broker + // iframe, so a harness page is what fetches them. + // + // In a context of its OWN, opened and closed before the actors'. When it shared + // theirs, one run had all four existing journeys collapse together — Alice and Bob + // signed in, then every `[data-testid=…]` vanished from their frames, which is what a + // RELOADED iframe looks like from here. Observed once and not reproduced, so the + // mechanism is a suspicion, not a finding: closing a broker page may end the session + // its profile-mates are using. Cheap to avoid, so avoided — the actors inherit + // nothing. Sequential contexts over the one profile dir; never two at once. + console.log("[e2e/app] exporting the wallet the barrier hands out..."); + const exportCtx = await launchWalletContext("wallet-export"); + let walletSize = 0; + try { + walletSize = await exportWalletNgw(exportCtx, sharedWalletFile); + } finally { + await closeContext("wallet-export", exportCtx); + } + + ctx = await launchWalletContext("actors"); + const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD); + closeServer = served.close; + const url = served.url; + console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`); + 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); @@ -302,9 +409,131 @@ async function main(): Promise { 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)); }); + + // 5. The path no journey walked: somebody who holds NOTHING. No wallet in the + // profile, no identifier anywhere, and the application's own address — not the one + // `signIn()` builds, which appends `?ng-id=` and so makes the barrier resolve from + // the URL and never appear. Two defects shipped green behind that shortcut: the + // application handed the page to the broker BEFORE the barrier could show (a + // first-time user landed on a login with no wallet and no way to get one), and the + // file the barrier offers was served by nobody, so its link pointed at a 404. + // + // Its own browser profile, deliberately: a wallet already in the profile is the + // other half of the same shortcut, and it is exactly what a first-time device + // does not have. + await journey("a first-time user, holding nothing, gets in through the barrier", async () => { + const newcomer = `newcomer-${t}`; + const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw"); + const fresh = await launchCleanProfileContext(); + let page: Page | null = null; + try { + page = await fresh.ctx.newPage(); + page.on("pageerror", (e) => console.error("[newcomer pageerror]", e.message)); + page.on("console", (m) => { + if (m.type() === "error") console.error("[newcomer console]", m.text()); + }); + + // The address a link in an email gives: the application, nothing appended. + await page.goto(url, { waitUntil: "domcontentloaded" }); + const gate = page.locator('[data-ng-eventually="access-gate"]'); + const identityField = page.locator('[data-testid="ng-identity-input"]'); + await identityField.waitFor({ state: "visible", timeout: 30000 }); + // Still on the application's own page — the hand-over has NOT happened. That is + // the whole of the first defect: `init()` navigated first, so everything the + // application did next ran in a document that no longer existed. + check( + "the barrier appears, and the page has not been handed to the broker yet", + page.url().startsWith(url), + page.url(), + ); + + // Step 1 of the barrier: the wallet file. Captured through the browser's own + // download, which is the only thing that can say whether the link RESOLVES. + const [download] = await Promise.all([ + page.waitForEvent("download", { timeout: 30000 }), + gate.locator("a[download]").click(), + ]); + const failure = await download.failure(); + if (failure === null) await download.saveAs(downloaded); + const bytes = fs.existsSync(downloaded) ? fs.statSync(downloaded).size : 0; + check( + "the barrier's download link serves a wallet file, not a 404", + failure === null && bytes > 0, + `failure=${failure ?? "none"} bytes=${bytes}`, + ); + + // Step 2: the password, read off the screen the way a user reads it. An injected + // value nobody looks at is a value that can be silently empty. + const password = ((await gate.locator("code").first().textContent()) ?? "").trim(); + check("the barrier shows the password for the import", password.length > 0, password); + + // Step 3: the wallet application, opened by the barrier's own link — a second tab, + // which is what `target="_blank"` gives. Nothing proves the downloaded bytes ARE a + // wallet except this import working, which is why the journey imports what it + // downloaded and nothing else. + const [walletPage] = await Promise.all([ + fresh.ctx.waitForEvent("page", { timeout: 30000 }), + gate.locator('a[target="_blank"]').click(), + ]); + await importWalletViaFile(walletPage, downloaded, password); + await walletPage.close().catch(() => {}); + + // Step 4: the identifier, typed. That is what settles the identity — and what + // `init()` then puts in the address bar before it hands the page over. + await identityField.fill(newcomer); + await page.locator('[data-testid="ng-identity-enter"]').click(); + + // The APPLICATION navigates, not the test; then the broker loads it back inside + // its iframe. `setupBrokerPage` is deliberately not used here — it would + // re-navigate and throw away the URL the application had just built. + await page.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); + check("the application hands the page to the broker itself", /nextgraph\./.test(page.url()), page.url()); + const frame = await completeBrokerLogin(page, url); + check( + "the application comes back inside the broker iframe", + frame !== page.mainFrame() && /nextgraph\./.test(page.mainFrame().url()), + `top=${page.mainFrame().url().slice(0, 40)}… app=${frame.url().slice(0, 40)}…`, + ); + + // THE assertion this journey exists for. The identifier crosses the round-trip in + // the URL and nowhere else — storage is partitioned by top-level site. Lose it and + // nothing throws: the iframe reads an empty identity, provisions a SECOND virtual + // space, and the user lands somewhere empty that looks like a working application. + check("the identifier survived the round-trip in the URL", frame.url().includes(`ng-id=${newcomer}`), frame.url()); + const arrived: Actor = { id: newcomer, frame, page }; + // A longer bound than `signIn`'s, and for a reason the other suite already + // measured: this profile is COLD — a wallet just imported, no local copy of any + // repo — so the first render waits on provisioning that round-trips the broker + // per repo (`run.ts` bounds the same cold-start generously for the same reason). + // A bound, not a sleep: it fails if the application never comes up. + await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); + const who = ((await frame.locator('[data-testid="who"]').textContent()) ?? "").trim(); + check("the application knows the newcomer as the identity he typed", who.includes(newcomer), who); + check( + "the barrier does not ask again inside the broker iframe", + (await frame.locator('[data-testid="ng-identity-input"]').count()) === 0, + ); + + // And it is a usable session, not merely a name on a screen. + await writeNote(arrived, "protected", "Première note", "écrite juste après la barrière"); + const list = (await frame.locator('[data-testid="notes"]').textContent()) ?? ""; + check( + "the newcomer writes a note and reads it back, as himself", + list.includes("Première note") && list.includes("écrite juste après la barrière"), + list.replace(/\s+/g, " ").slice(0, 70), + ); + } finally { + if (page) await closeQuietly("the newcomer's page", () => page!.close()); + await closeContext("clean-profile", fresh.ctx); + try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); } finally { - await ctx?.close().catch(() => {}); - closeServer(); + // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already + // gone never resolves, and this `finally` is where that hang swallowed the summary. + if (ctx) await closeContext("actors", ctx); + closeServer?.(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } } const failed = results.filter((r) => !r.ok).length; @@ -316,4 +545,9 @@ async function main(): Promise { process.exit(failed === 0 ? 0 : 1); } -void main(); +main().catch((e) => { + // Anything the journeys did not catch — a refused run lock, a browser lost during setup. + // Reported and exited, never left to become an unhandled rejection nobody sees. + console.error("[e2e/app] fatal:", e); + process.exit(1); +}); diff --git a/packages/polyfill/e2e/polyfill-entry.ts b/packages/polyfill/e2e/polyfill-entry.ts index 1512640..aa5a02c 100644 --- a/packages/polyfill/e2e/polyfill-entry.ts +++ b/packages/polyfill/e2e/polyfill-entry.ts @@ -18,6 +18,7 @@ import { ng as realNg, init as realInit } from "@ng-org/web"; import { configure, docs, + init, subscribeDoc, subscribeDocs, readUnion, @@ -107,6 +108,22 @@ configure({ sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" }, }); +/** + * Who this page opens as — and why it has to open as somebody. + * + * The boot below goes through the POLYFILL's `init`, not the injected one, so that the + * 42 checks run over the ordering an application actually gets: settle the identity, THEN + * hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would + * raise the barrier instead and never hand over, so the harness supplies one. + * + * Set BEFORE `configureStoreRegistry` deliberately: until the registry is wired, + * `setCurrentUser` fires no connection work (`bootstrap.ts`), so this costs the batch + * neither an account nor a broker round-trip. Every check that cares about identity sets + * its own anyway — this one is only what the page opened as. + */ +const BOOT_IDENTITY = "e2e-harness"; +setCurrentUser(BOOT_IDENTITY); + configureStoreRegistry({ // The registry (+ subscribe/inbox/read-model) reach the session // through this. It resolves once the broker connects. @@ -189,6 +206,16 @@ const identity = new IdentityStore( async accessGateFirstVisit(raw: string) { setCurrentUser(null); try { window.localStorage.removeItem("ng-eventually:identity"); } catch {} + // A first visit has no `?ng-id=` either, and the URL is the branch the gate consults + // FIRST — so clearing storage alone stopped describing a first visit the moment the + // boot started settling an identity (which writes the parameter, as every settling + // path must). Leaving it there would make this check pass for the wrong reason on a + // gate that had stopped asking at all. + try { + const withoutIdentity = new URL(window.location.href); + withoutIdentity.searchParams.delete("ng-id"); + window.history.replaceState(null, "", withoutIdentity.toString()); + } catch {} const done = ensureIdentity(); const gate = document.querySelector('[data-ng-eventually="access-gate"]'); const root = gate?.shadowRoot ?? null; @@ -851,7 +878,7 @@ const identity = new IdentityStore( // caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does // NOT yet enforce per-doc read caps here (one shared wallet reads everything). // We test what the SDK enforces: the in-memory read-filtered VIEW, which after - // P1a is KEY POSSESSION — you read what your keyring holds, nothing else. + // cap-surface is KEY POSSESSION — you read what your keyring holds, nothing else. capsReadFilter() { resetCaps(); injectedSetItems = [ @@ -1028,19 +1055,26 @@ const identity = new IdentityStore( // ── Connect to the real broker ───────────────────────────────────────────── // Mirrors ngSession.ts: register the init callback; the broker (this iframe is // loaded by it) drives the connection and calls back with the session. +// +// Through the POLYFILL's `init`, not the injected `realInit` — even though `realInit` is +// what ends up being called (it is what `configure` injects, above). Calling it directly +// skipped the forwarder that settles the identity before delegating, so the 42 checks ran +// over an ordering no application has, and the defect that ordering exists to prevent — +// the hand-over happening before the identity reaches the address bar — could not have +// been caught here. What an application writes is this line. (async () => { try { - await (realInit as any)( - (event: any) => { - session = event.session as BrokerSession; + await init( + (event: { session: BrokerSession }) => { + session = event.session; state.status = "connected"; sessionResolve(session); }, true, [], ); - } catch (e: any) { + } catch (e) { state.status = "error"; - state.error = String(e?.message ?? e); + state.error = e instanceof Error ? e.message : String(e); } })(); diff --git a/packages/polyfill/e2e/run-lock.ts b/packages/polyfill/e2e/run-lock.ts new file mode 100644 index 0000000..39c245d --- /dev/null +++ b/packages/polyfill/e2e/run-lock.ts @@ -0,0 +1,131 @@ +/** + * One run at a time over the shared wallet profile. + * + * ── Why ────────────────────────────────────────────────────────────────────── + * Both suites call `ensureWallet()`, and its first act is `fs.rmSync(PROFILE_DIR)` — it + * discards the previous batch's physical user on purpose (see `broker.ts`). Started while + * another run is alive, that deletes the profile out from under a browser which is USING + * it, and the first run then fails somewhere far from the cause, looking like a product + * defect. That has already cost several undecidable measurements: a suite blamed for a + * hang that was really a second run wiping its wallet. + * + * So the exclusion is made structural rather than remembered. + * + * ── Fail, not wait ─────────────────────────────────────────────────────────── + * A second run is REFUSED, immediately, naming the holder. Queueing would be the wrong + * answer for a harness: these batches run for minutes, and a command that silently blocks + * for a quarter of an hour is the same disease as the hang this was written alongside — + * you cannot tell it from a freeze. A refusal is legible in one line and costs nothing. + * + * ── Where the file lives ───────────────────────────────────────────────────── + * Under the system temp dir, NOT inside the profile it guards: `ensureWallet` deletes that + * directory wholesale, which would erase the lock at the exact moment it is protecting + * something. Naming it after the profile's path keeps one lock per guarded profile, and + * keeps it out of the repository (nothing to gitignore, nothing to commit by accident). + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +interface LockRecord { + pid: number; + suite: string; + startedAt: string; +} + +function lockPathFor(guarded: string): string { + const slug = guarded.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return path.join(os.tmpdir(), `ng-eventually-e2e-${slug}.lock`); +} + +/** Is that process still alive? Signal 0 tests for existence without touching it. */ +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM means it exists and is someone else's — still alive, still holding the lock. + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readRecord(lockPath: string): LockRecord | null { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf-8")); + if (parsed && typeof parsed === "object" && typeof (parsed as LockRecord).pid === "number") { + return parsed as LockRecord; + } + return null; + } catch { + return null; + } +} + +/** + * Take the lock for `suite` over `guarded`, or throw naming who holds it. + * + * A lock left by a process that no longer exists is taken over — a run killed mid-batch + * (which is how every one of this harness's hangs ended) must not make the next one + * unrunnable. That check is on the OS's view of the pid, not on the file's age: a timeout + * would either strand a slow-but-healthy batch or hand the profile to a second run while + * the first still holds it, and both are the corruption this exists to stop. + */ +export function acquireRunLock(suite: string, guarded: string): void { + const lockPath = lockPathFor(guarded); + const record: LockRecord = { pid: process.pid, suite, startedAt: new Date().toISOString() }; + + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(lockPath, JSON.stringify(record), { flag: "wx" }); + installRelease(lockPath); + return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + const held = readRecord(lockPath); + if (held !== null && isAlive(held.pid)) { + const ageMin = Math.round((Date.now() - Date.parse(held.startedAt)) / 60000); + throw new Error( + `[e2e] refusing to start: another e2e run holds ${guarded}.\n` + + ` holder: ${held.suite} (pid ${held.pid}, started ${held.startedAt}, ${ageMin} min ago)\n` + + " Two runs share one wallet profile, and each one's setup DELETES it — so the\n" + + " second would corrupt the first. Wait for it, or stop it, then run again.\n" + + ` If that process is gone, remove ${lockPath}.`, + ); + } + // Nobody is behind it: a killed run's leftover. Take it over and say so. + console.warn( + `[e2e] taking over a stale run lock (${held === null ? "unreadable" : `pid ${held.pid} is gone`}) — ${lockPath}`, + ); + fs.rmSync(lockPath, { force: true }); + } + } + throw new Error(`[e2e] could not take the run lock at ${lockPath} (raced twice)`); +} + +/** + * Release on the way out, including the ways out nobody plans for. + * + * `exit` covers the normal end and `process.exit()`, which is how both suites finish; the + * signal handlers cover Ctrl-C and `kill`, which is how a hung batch ends. A lock that + * outlives its run is only a nuisance — the takeover above clears it — but leaving one + * behind on every interrupt would make the nuisance the norm. + */ +function installRelease(lockPath: string): void { + const release = (): void => { + const held = readRecord(lockPath); + if (held !== null && held.pid !== process.pid) return; // someone else's now; leave it + try { + fs.rmSync(lockPath, { force: true }); + } catch { + /* the takeover path handles whatever is left */ + } + }; + process.on("exit", release); + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => { + release(); + process.exit(130); + }); + } +} diff --git a/packages/polyfill/e2e/run.ts b/packages/polyfill/e2e/run.ts index 7cb3cc0..5a8e968 100644 --- a/packages/polyfill/e2e/run.ts +++ b/packages/polyfill/e2e/run.ts @@ -19,12 +19,17 @@ import type { Frame, Page, BrowserContext } from "playwright"; import { buildBundle, serveHarness, + closeContext, ensureWallet, launchWalletContext, launchCleanProfileContext, importWalletViaFile, + newPage, setupBrokerPage, + PROFILE_DIR, } from "./broker"; +import { armSuiteDeadline, closeQuietly, within } from "./deadline"; +import { acquireRunLock } from "./run-lock"; type Check = { name: string; ok: boolean; detail?: string }; const results: Check[] = []; @@ -36,27 +41,42 @@ function record(name: string, ok: boolean, detail?: string): void { function check(name: string, cond: boolean, detail?: string): void { record(name, !!cond, detail); } +/** + * One step of the batch, under its own deadline. + * + * The bound is what makes the catch honest: recording a FAIL is the right answer for a step + * that fails, but a step that never RETURNS is caught by nothing — and from the outside + * that is indistinguishable from a machine that has stopped. + */ async function step(name: string, fn: () => Promise): Promise { try { - await fn(); + await within(`the step "${name}"`, STEP_MS, fn); } catch (e: any) { record(name, false, "threw: " + String(e?.message ?? e)); } } -// A short helper: call a bridge method inside the iframe. +/** + * Call a bridge method inside the iframe — under a deadline, because `frame.evaluate()` + * has none. + * + * This is the single most important bound in the file: every one of the thirty-odd + * `sdk(...)` calls below is an `evaluate`, and Playwright will wait on one for ever. A + * bridge method that never settles — a broker round-trip that gets no answer — used to + * stop the batch dead with nothing printed and no way to tell which call it was. The name + * carried into the error is the method's own, so the report says which. + */ function sdk(frame: Frame, method: string, ...args: unknown[]): Promise { - return frame.evaluate( - ([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])), - [method, args] as const, + return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () => + frame.evaluate( + ([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])), + [method, args] as const, + ), ) as Promise; } function sdkGet(frame: Frame, method: string, ...args: unknown[]): Promise { // 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; + return sdk(frame, method, ...args); } /** @@ -80,7 +100,7 @@ async function faithfulReconnect( ctx: BrowserContext, url: string, ): Promise<{ page: Page; frame: Frame }> { - const p = await ctx.newPage(); + const p = await newPage("the faithful reconnect", ctx); p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message)); p.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:reconnect]", m.text()); @@ -98,8 +118,26 @@ async function faithfulReconnect( * browser, and the suite reported `Target page, context or browser has been closed` — * which reads as an application bug and was twice diagnosed as one. A budget belongs to * the thing that knows what it is spending it on, and it must say so when it runs out. + * + * ENFORCED WHILE IT RUNS, not merely checked at the end. Declared as 45 min and only ever + * asserted after the last step, it could not fire on the one case that matters — a batch + * that never reaches its last step. 25 min is the number now, against a healthy batch of + * ~3.3 min: the drift the old figure was sized against (a physical user growing across + * batches, so an O(size) cold resync) is gone since each batch mints its own user, and a + * budget that cannot interrupt anything is not a budget. */ -const BATCH_BUDGET_MS = 45 * 60 * 1000; +const BATCH_BUDGET_MS = 25 * 60 * 1000; +/** + * One `__sdk` bridge call. The slowest measured on this broker is a reconnect read at + * ~90-105s (open-repo heal + anti-fork retry + anchored read, all round-tripping); four + * minutes is well past that and still names a stuck call in minutes rather than never. + */ +const BRIDGE_MS = 4 * 60 * 1000; +/** + * One step. The longest are the reconnect contracts, which poll for up to 120s per scope + * on top of a fresh broker login — a few minutes when healthy, ten before we call it stuck. + */ +const STEP_MS = 10 * 60 * 1000; const batchStart = Date.now(); /** * The slowest cold resynchronisation of the batch — the number that drifted from 250s to @@ -123,6 +161,10 @@ function assertWithinBudget(): void { } async function main(): Promise { + // Before anything touches the shared profile: this batch is about to DELETE it (see + // `ensureWallet`), so a second run alive right now would be destroyed by this one. + acquireRunLock("the polyfill suite (e2e/run.ts)", PROFILE_DIR); + armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS); console.log("[e2e] building SDK page bundle..."); buildBundle(); console.log("[e2e] ensuring dedicated lib wallet..."); @@ -133,8 +175,8 @@ async function main(): Promise { let ctx: BrowserContext | null = null; let page: Page | null = null; try { - ctx = await launchWalletContext(); - page = await ctx.newPage(); + ctx = await launchWalletContext("sdk-harness"); + page = await newPage("the SDK harness", ctx); page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("console", (m) => { if (m.type() === "error") console.error("[iframe console]", m.text()); @@ -456,7 +498,7 @@ async function main(): Promise { const launched = await launchCleanProfileContext(); cleanCtx = launched.ctx; cleanDir = launched.dir; - cleanPage = await cleanCtx.newPage(); + cleanPage = await newPage("the clean-profile session", cleanCtx); cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message)); cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); }); @@ -480,8 +522,8 @@ async function main(): Promise { `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 */ } + if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close()); + if (cleanCtx) await closeContext("clean-profile", cleanCtx); try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ } try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ } } @@ -566,7 +608,7 @@ async function main(): Promise { ); } } finally { - try { if (rp) await rp.close(); } catch { /* ignore */ } + if (rp) await closeQuietly("the reconnect page", () => rp!.close()); } }); @@ -623,7 +665,7 @@ async function main(): Promise { : `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 */ } + if (rp) await closeQuietly("the reconnect page", () => rp!.close()); } }); @@ -775,8 +817,10 @@ async function main(): Promise { await sdk(frame, "stateProbeStop"); }); } finally { - try { if (page) await page.close(); } catch { /* ignore */ } - try { if (ctx) await ctx.close(); } catch { /* ignore */ } + // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already + // gone never resolves, and this `finally` is where that hang swallowed the summary. + if (page) await closeQuietly("the SDK harness page", () => page!.close()); + if (ctx) await closeContext("sdk-harness", ctx); closeServer(); } diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index dbdd781..8f87cbe 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -370,10 +370,11 @@ async function resolveIdentity(): Promise { * the address bar as it finds it; settling after the hand-over sends the round-trip off * without the identifier, which fails silently (see {@link rememberIdentity}). That used to * be a rule an application had to follow, and following it hung — so the polyfill's `init()` - * awaits {@link settleIdentity} itself. This call is safe in any position: after `init()` it - * finds the identity already set, and alongside it — which is what an application's - * bootstrap actually does — it JOINS the settling in flight rather than raising a second - * barrier. Either way it goes on to the connection work, which is what it adds. + * awaits {@link settleIdentity} itself. This call is safe FROM `init()` ONWARDS: after it, the identity is + * already set; alongside it — what an application's bootstrap actually does — it JOINS the + * settling in flight rather than raising a second barrier. It is NOT safe strictly BEFORE + * `init()`: the connection work it adds awaits a session only `init()`'s callback resolves, + * so awaiting it first deadlocks in silence. Either way it goes on to the connection work, which is what it adds. * * **It RETURNS the identity it settled**, and that is not a convenience — it is the only * way an application can know who it is. Upstream the question does not arise: an app