c5b4703687
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.
575 lines
27 KiB
TypeScript
575 lines
27 KiB
TypeScript
/**
|
|
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
|
|
* `@ng-eventually/polyfill`, fully separate from any consumer app's profile.
|
|
*
|
|
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
|
|
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
|
|
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
|
|
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
|
|
* minimal polyfill page (polyfill-entry.ts) inside the broker iframe.
|
|
*/
|
|
|
|
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
|
|
import { execSync } from "node:child_process";
|
|
import * as http from "node:http";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
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");
|
|
/**
|
|
* 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");
|
|
|
|
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, ".."),
|
|
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>`;
|
|
return serveOnEphemeralPort((req, res) => {
|
|
if (req.url === "/polyfill-entry.js") {
|
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
res.end(bundle);
|
|
} else {
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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());
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
/**
|
|
* 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> {
|
|
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 (${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 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 });
|
|
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 closeContext("wallet-creation", ctx);
|
|
}
|
|
fs.writeFileSync(USER_CONSUMED_MARKER, new Date().toISOString());
|
|
}
|
|
|
|
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 `.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.
|
|
*
|
|
* 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 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 });
|
|
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 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,
|
|
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);
|
|
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(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" });
|
|
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();
|
|
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;
|
|
}
|