da6ef4b8b8
La suite se ralentissait elle-même, de façon monotone. Chaque batterie crée ~11 identités virtuelles FRAÎCHES (`@alice-…`, `@owner-…`, `@recon-…`), chacune avec ses trois documents de scope et son inbox, et toutes atterrissaient dans le MÊME user physique — un wallet créé le 10 juillet et réutilisé depuis, que rien ne nettoyait. Or une resynchronisation à froid est O(taille du user physique), ce que la doc de cette bibliothèque énonce elle-même. D'où 250s il y a une semaine, 286s avant-hier, et une batterie qui a fini par dépasser les 20 minutes. Les identités fraîches ne sont pas la faute : ce sont elles qui rendent une batterie reproductible, une inbox stable accumulant sinon les dépôts des runs précédents. La faute était de conserver le user physique qui les héberge. Mesuré : 42/42 en **3,6 min** au lieu de 20+, synchro à froid la plus lente à **30s** au lieu de 286s. Le profil reste persistant À L'INTÉRIEUR d'une batterie — CONTRACT 1 et 2 testent précisément cela (reconnexion fidèle sur le même profil, absence de fork de compte au travers). Deux garde-fous pour que la prochaine dérive se voie : - **Le budget appartient au runner**, qui échoue en nommant la cause probable. Un `timeout` posé autour de la commande tuait le navigateur, et la suite rapportait « Target page, context or browser has been closed » — un message qui se lit comme un défaut applicatif, et que j'ai diagnostiqué deux fois de travers avant de comparer les durées. - **La synchro à froid remonte dans le résumé.** C'est le nombre qui a dérivé pendant un mois sans que personne le regarde, parce qu'il n'apparaissait qu'au détour de la ligne de détail d'une étape.
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
/**
|
|
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
|
|
* `@ng-eventually/client`, fully separate from any consumer app's profile.
|
|
*
|
|
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
|
|
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
|
|
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
|
|
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
|
|
* minimal SDK page (sdk-entry.ts) inside the broker iframe.
|
|
*/
|
|
|
|
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
|
|
import { execSync } from "node:child_process";
|
|
import * as http from "node:http";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
|
|
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
|
|
const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready");
|
|
export const WALLET_NAME = "ng-eventually-e2e";
|
|
export const WALLET_PASSWORD = "ng-eventually-e2e";
|
|
|
|
const ENTRY = path.resolve(__dirname, "sdk-entry.ts");
|
|
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js");
|
|
|
|
const LAUNCH_ARGS = [
|
|
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
|
"--allow-insecure-localhost",
|
|
"--disable-web-security",
|
|
];
|
|
|
|
function resolveChromePath(): string | undefined {
|
|
const p = chromium
|
|
.executablePath()
|
|
.replace("chrome-headless-shell", "chrome")
|
|
.replace("chromium_headless_shell", "chromium");
|
|
return p.includes("headless") ? undefined : p;
|
|
}
|
|
|
|
export function buildBundle(): void {
|
|
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
|
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
|
stdio: "pipe",
|
|
cwd: path.resolve(__dirname, ".."),
|
|
});
|
|
}
|
|
|
|
export function serveHarness(): Promise<{ url: string; close: () => void }> {
|
|
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually sdk e2e</title></head><body><div id="root"></div><script type="module" src="/sdk-entry.js"></script></body></html>`;
|
|
const server = http.createServer((req, res) => {
|
|
if (req.url === "/sdk-entry.js") {
|
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
res.end(bundle);
|
|
} else {
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
}
|
|
});
|
|
return new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const port = (server.address() as { port: number }).port;
|
|
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
|
|
* persisted in PROFILE_DIR for the duration of the batch.
|
|
*
|
|
* ── One PHYSICAL user per batch, not one forever ──────────────────────────
|
|
* This used to reuse a single wallet across every run, guarded by a ready marker. That
|
|
* made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual
|
|
* identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and
|
|
* an inbox, and they all land in the SAME physical user. Nothing ever removed them. A
|
|
* cold resynchronisation is O(the physical user's size) — which this library's own docs
|
|
* state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single
|
|
* sync step, against 250s a week earlier, and the drift was invisible because no one
|
|
* measured it.
|
|
*
|
|
* The fresh identities are not the mistake — they are what makes a batch reproducible
|
|
* (a stable inbox accumulates its past runs' deposits otherwise). The mistake was
|
|
* keeping the physical user that holds them. So: a new one per batch, which also makes
|
|
* the cold-sync duration comparable from one run to the next instead of being a number
|
|
* that only ever grows.
|
|
*
|
|
* What this does NOT change: the profile stays persistent WITHIN a batch, because
|
|
* CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and
|
|
* the absence of an account fork across it).
|
|
*/
|
|
export async function ensureWallet(): Promise<void> {
|
|
if (fs.existsSync(WALLET_READY_MARKER)) {
|
|
const age = Date.now() - fs.statSync(WALLET_READY_MARKER).mtimeMs;
|
|
console.log(
|
|
`[e2e] discarding the previous batch's wallet (${Math.round(age / 60000)} min old) — ` +
|
|
"a physical user is per-batch, see ensureWallet",
|
|
);
|
|
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
|
}
|
|
console.log("[e2e] creating this batch's wallet on nextgraph.eu...");
|
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
|
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
headless: true,
|
|
executablePath: resolveChromePath(),
|
|
args: LAUNCH_ARGS,
|
|
});
|
|
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
page.on("pageerror", () => {});
|
|
try {
|
|
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
const createButton = page.getByText("Create Wallet", { exact: true });
|
|
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
|
await createButton.click();
|
|
|
|
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
|
const acceptButton = page.getByText("I accept", { exact: true });
|
|
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
|
await acceptButton.click();
|
|
|
|
const usernameInput = page.locator("#username-input");
|
|
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
|
await usernameInput.fill(WALLET_NAME);
|
|
const passwordInput = page.locator("#password-input");
|
|
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
|
await passwordInput.fill(WALLET_PASSWORD);
|
|
|
|
const submitButton = page.getByText("create my wallet", { exact: false });
|
|
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
|
await submitButton.click();
|
|
|
|
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
|
await page.waitForTimeout(2000);
|
|
|
|
// First login → bootstrap the verifier repos from the broker.
|
|
const walletLink = page.getByText("Click here to login with your wallet");
|
|
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await walletLink.click();
|
|
await page.waitForTimeout(1000);
|
|
}
|
|
const loginPassword = page.locator('input[type="password"]');
|
|
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
|
await loginPassword.fill(WALLET_PASSWORD);
|
|
await loginPassword.press("Enter");
|
|
await page.waitForTimeout(10000);
|
|
console.log("[e2e] dedicated lib wallet created + bootstrapped");
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString());
|
|
}
|
|
|
|
export async function launchWalletContext(): Promise<BrowserContext> {
|
|
return chromium.launchPersistentContext(PROFILE_DIR, {
|
|
headless: true,
|
|
executablePath: resolveChromePath(),
|
|
args: LAUNCH_ARGS,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
|
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
|
|
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
|
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
|
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
|
*
|
|
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
|
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
|
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
|
* wallet that has never run the app. Caller cleans up ctx + dir.
|
|
*/
|
|
export async function createFreshWalletContext(): Promise<{
|
|
ctx: BrowserContext;
|
|
dir: string;
|
|
name: string;
|
|
}> {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
|
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
const ctx = await chromium.launchPersistentContext(dir, {
|
|
headless: true,
|
|
executablePath: resolveChromePath(),
|
|
args: LAUNCH_ARGS,
|
|
});
|
|
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
page.on("pageerror", () => {});
|
|
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
const createButton = page.getByText("Create Wallet", { exact: true });
|
|
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
|
await createButton.click();
|
|
|
|
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
|
const acceptButton = page.getByText("I accept", { exact: true });
|
|
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
|
await acceptButton.click();
|
|
|
|
const usernameInput = page.locator("#username-input");
|
|
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
|
await usernameInput.fill(name);
|
|
const passwordInput = page.locator("#password-input");
|
|
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
|
await passwordInput.fill(WALLET_PASSWORD);
|
|
|
|
const submitButton = page.getByText("create my wallet", { exact: false });
|
|
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
|
await submitButton.click();
|
|
|
|
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
|
await page.waitForTimeout(2000);
|
|
|
|
// First login → bootstrap the verifier repos from the broker (this is what a
|
|
// brand-new wallet does on its very first unlock).
|
|
const walletLink = page.getByText("Click here to login with your wallet");
|
|
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await walletLink.click();
|
|
await page.waitForTimeout(1000);
|
|
}
|
|
const loginPassword = page.locator('input[type="password"]');
|
|
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
|
await loginPassword.fill(WALLET_PASSWORD);
|
|
await loginPassword.press("Enter");
|
|
await page.waitForTimeout(10000);
|
|
await page.close().catch(() => {});
|
|
return { ctx, dir, name };
|
|
}
|
|
|
|
/**
|
|
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
|
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
|
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
|
|
* a session over it starts with an empty `self.repos`. Caller must import the wallet
|
|
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
|
|
* and the dir so the caller can clean it up.
|
|
*/
|
|
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
|
|
const ctx = await chromium.launchPersistentContext(dir, {
|
|
headless: true,
|
|
executablePath: resolveChromePath(),
|
|
args: LAUNCH_ARGS,
|
|
});
|
|
return { ctx, dir };
|
|
}
|
|
|
|
/**
|
|
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
|
|
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
|
|
* this the profile holds the wallet — but NOT the repos' local cache — so the next
|
|
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
|
|
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
|
|
*/
|
|
export async function importWalletViaFile(page: Page, ngwPath: string): Promise<void> {
|
|
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
|
|
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
|
|
await page.waitForTimeout(3000);
|
|
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
|
|
await page.setInputFiles('input[type=file]', ngwPath);
|
|
const passwordInput = page.locator('input[type=password]').first();
|
|
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
|
await passwordInput.fill(WALLET_PASSWORD);
|
|
await passwordInput.press("Enter");
|
|
const confirm = page.getByRole("button", { name: /Confirm/i });
|
|
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
|
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
|
}
|
|
|
|
/**
|
|
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
|
|
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
|
|
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
|
|
*/
|
|
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
|
|
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
|
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
|
|
|
|
const loginButton = page.getByText("Login", { exact: true });
|
|
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await loginButton.click();
|
|
await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {});
|
|
}
|
|
|
|
const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1"));
|
|
const walletLink = page.getByText("Click here to login with your wallet", { exact: false });
|
|
const loginDeadline = Date.now() + 25000;
|
|
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) {
|
|
await walletLink.click();
|
|
await page.waitForTimeout(1000);
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
|
|
await passwordInput.fill(WALLET_PASSWORD);
|
|
await passwordInput.press("Enter");
|
|
await page.waitForTimeout(3000);
|
|
}
|
|
}
|
|
|
|
let appFrame: Frame | null = null;
|
|
const deadline = Date.now() + 30000;
|
|
while (Date.now() < deadline) {
|
|
for (const f of page.frames()) {
|
|
if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) {
|
|
appFrame = f;
|
|
break;
|
|
}
|
|
}
|
|
if (appFrame) break;
|
|
for (const iframe of await page.locator("iframe").all()) {
|
|
const src = await iframe.getAttribute("src");
|
|
if (src && src.includes("127.0.0.1")) {
|
|
const el = await iframe.elementHandle();
|
|
appFrame = (await el?.contentFrame()) ?? null;
|
|
if (appFrame) break;
|
|
}
|
|
}
|
|
if (appFrame) break;
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
if (!appFrame) {
|
|
const frames = page.frames().map((f) => f.url());
|
|
throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
|
|
}
|
|
return appFrame;
|
|
}
|