3046ead08f
Défensif : loadShim/resolveAccount/ensureAccount ouvrent l'anchor (private-store-root) avant de lire/écrire le compte shim, comme le fait déjà readScopeIndex. Robustesse same-session si l'anchor est là-mais-pas-encore-souscrit. NB (vérifié nextgraph-rs) : sur le login broker normal, le bootstrap charge le private-store dans self.repos AVANT de rendre la session à JS → un wallet FRAIS retourne 0 rows (pas RepoNotFound) et provisionne. Il n'existe AUCUN primitif JS pour ouvrir un repo *inconnu* : ce heal n'est pas un remède à un store non bootstrappé (limite NextGraph), juste une robustesse d'ouverture same-session. Tests : cold-start-anchor.test.ts (rouge-avant/vert-après unit) ; harness e2e repro-fresh-wallet (mint un wallet neuf par run — comble le trou "aucun test de démarrage à froid sur wallet vierge"). Fakes anti-fork/watch-shape honorent désormais la barrière first-State dont dépend le heal. e2e réel 42/42. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
13 KiB
TypeScript
310 lines
13 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. Mirrors Festipod's ensureAuth but with the lib's own
|
|
* wallet name + profile. Idempotent via the ready marker.
|
|
*/
|
|
export async function ensureWallet(): Promise<void> {
|
|
if (fs.existsSync(WALLET_READY_MARKER)) {
|
|
console.log("[e2e] dedicated lib wallet present — skipping creation");
|
|
return;
|
|
}
|
|
console.log("[e2e] creating dedicated lib 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;
|
|
}
|