/**
* 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 = `
ng-eventually sdk e2e`;
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 {
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 {
return chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
}
/**
* 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 {
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 {
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;
}