test(e2e): le parcours d'un primo-arrivant, et un harnais qui échoue au lieu de se pendre
Aucun parcours n'avait jamais marché le chemin d'un nouvel arrivant : tous pré-injectaient l'identifiant dans l'URL, ce qui fait résoudre l'identité sans jamais afficher la barrière. La suite était verte par-dessus un lien de téléchargement pointant sur un fichier que personne ne servait — et le serveur de test répondait la page HTML de l'application pour tout chemin inconnu, donc un fichier manquant ne POUVAIT pas échouer. Le nouveau parcours part d'un profil vide : barrière, téléchargement réel, import dans l'application portefeuille, saisie de l'identifiant, remise au broker, retour dans l'iframe. Il vérifie neuf points, dont celui qui compte — l'identifiant a survécu et l'identité rapportée est celle qui a été saisie. Le harnais, lui, se pendait au lieu d'échouer. Cause observée : le tuyau devtools de Chromium lâche et Playwright n'émet ni close ni disconnected, si bien que la suite bloquait dans son propre nettoyage sans imprimer ni résumé ni l'échec déjà en route. Toutes les attentes sont désormais bornées et nomment ce qu'elles attendaient ; vérifié en cassant délibérément une attente, et observé en conditions réelles — trois minutes et « gave up waiting for: alice to sign in » là où j'ai tué trois exécutions d'une heure ce matin. Deux exécutions simultanées ne se détruisent plus : verrou atomique sur le profil, et récupération d'un navigateur laissé par une exécution tuée. Le marqueur devient .user-consumed — il n'a jamais attesté d'une disponibilité, seulement qu'un lot avait déjà pris l'utilisateur de ce profil. Au passage, la destruction du profil dépendait du marqueur, écrit en FIN de lot : une exécution tuée avant laissait un profil que la suivante réutilisait, et héritait de sa casse. Elle dépend maintenant du profil. La suite applicative reste non mesurée sur cette machine : un conteneur en boucle de redémarrage recycle son interface réseau, et sept exécutions sur dix échouent sur le transport. Trois sont passées 21/21.
This commit is contained in:
+281
-39
@@ -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<Socket>();
|
||||
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 = `<!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) => {
|
||||
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<BrowserContext>();
|
||||
|
||||
/**
|
||||
* 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<BrowserContext> {
|
||||
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<void> {
|
||||
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<Page> {
|
||||
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 `<launched>`).
|
||||
*
|
||||
* 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 `<host>-<pid>`. 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BrowserContext> {
|
||||
return chromium.launchPersistentContext(PROFILE_DIR, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
export async function launchWalletContext(label = "wallet"): Promise<BrowserContext> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
export async function importWalletViaFile(
|
||||
page: Page,
|
||||
ngwPath: string,
|
||||
password: string = WALLET_PASSWORD,
|
||||
): 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);
|
||||
@@ -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<Frame> {
|
||||
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<Frame> {
|
||||
const loginButton = page.getByText("Login", { exact: true });
|
||||
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await loginButton.click();
|
||||
|
||||
@@ -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<Pending>();
|
||||
|
||||
/** 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<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
|
||||
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<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let entry!: Pending;
|
||||
const interrupted = new Promise<never>((_, 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<unknown>): Promise<void> {
|
||||
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;
|
||||
@@ -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<void>): Promise<void> {
|
||||
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 = '<script type="module" src="/app.js"></script>';
|
||||
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,
|
||||
`<script>globalThis.__NOTEBOOK_WALLET_PASSWORD__ = ${JSON.stringify(walletPassword)};</script>\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<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());
|
||||
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<string> {
|
||||
/** 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(() => {});
|
||||
await closeQuietly(`${a.id}'s previous page`, () => a.page.close());
|
||||
return signIn(ctx, appUrl, a.id);
|
||||
}
|
||||
|
||||
// ── the journeys ────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void>): Promise<void> {
|
||||
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<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,
|
||||
return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
|
||||
frame.evaluate(
|
||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
),
|
||||
) as Promise<T>;
|
||||
}
|
||||
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
// Same as sdk() but for synchronous getters (no await inside the bridge).
|
||||
return frame.evaluate(
|
||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
) as Promise<T>;
|
||||
return sdk<T>(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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
`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<void> {
|
||||
);
|
||||
}
|
||||
} 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<void> {
|
||||
: `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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -370,10 +370,11 @@ async function resolveIdentity(): Promise<PrincipalId> {
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user