From a677a7c85fdb93c75cf1f8d5623b7842d68c49da Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Mon, 18 May 2026 00:33:31 +0100 Subject: [PATCH 1/3] feat(vault): e2e fixture scaffold (page objects, seeded wallets, route helpers) --- services/vault/e2e/README.md | 139 +++++++++++++ services/vault/e2e/fixtures-smoke.spec.ts | 84 ++++++++ services/vault/e2e/fixtures/index.ts | 17 ++ services/vault/e2e/fixtures/networkRoutes.ts | 172 ++++++++++++++++ services/vault/e2e/fixtures/seededWallets.ts | 153 ++++++++++++++ services/vault/e2e/fixtures/test.ts | 130 ++++++++++++ services/vault/e2e/pages/AppShell.ts | 52 +++++ services/vault/e2e/pages/Dashboard.ts | 30 +++ services/vault/e2e/pages/DepositModal.ts | 67 +++++++ services/vault/e2e/pages/WithdrawModal.ts | 38 ++++ services/vault/e2e/pages/index.ts | 4 + services/vault/package.json | 3 +- services/vault/playwright.config.ts | 13 ++ services/vault/scripts/e2e-env.mjs | 200 +++++++++++++++++++ services/vault/tsconfig.lib.json | 2 +- 15 files changed, 1102 insertions(+), 2 deletions(-) create mode 100644 services/vault/e2e/README.md create mode 100644 services/vault/e2e/fixtures-smoke.spec.ts create mode 100644 services/vault/e2e/fixtures/networkRoutes.ts create mode 100644 services/vault/e2e/fixtures/seededWallets.ts create mode 100644 services/vault/e2e/fixtures/test.ts create mode 100644 services/vault/e2e/pages/AppShell.ts create mode 100644 services/vault/e2e/pages/Dashboard.ts create mode 100644 services/vault/e2e/pages/DepositModal.ts create mode 100644 services/vault/e2e/pages/WithdrawModal.ts create mode 100644 services/vault/e2e/pages/index.ts create mode 100644 services/vault/scripts/e2e-env.mjs diff --git a/services/vault/e2e/README.md b/services/vault/e2e/README.md new file mode 100644 index 000000000..73ccda867 --- /dev/null +++ b/services/vault/e2e/README.md @@ -0,0 +1,139 @@ +# Vault E2E + +Playwright suite for the vault dApp. The suite is **mock-first**: no +real Bitcoin regtest, no anvil, no live vault provider. Backend +responses are intercepted at the page-route layer with deterministic +payloads so tests stay fast and reproducible without provisioning +external services. + +> This trade-off is deliberate. See issue #1589: full devnet harnessing +> blocks on the external contracts repo and a full VP implementation. +> Mock-first lets the per-flow tickets (#1590-#1602) land now and gives +> us coverage of the React surfaces, wallet wiring, and error paths. +> A future ticket can swap the route handlers for a real Anvil/bitcoind +> harness without rewriting the specs themselves. + +## Layout + +``` +services/vault/e2e/ +├── fixtures/ +│ ├── mockBtcWallet.ts Deterministic IBTCProvider +│ ├── mockEthWallet.ts Deterministic viem WalletClient +│ ├── walletInjection.ts window.__BABYLON_E2E_WALLETS__ bridge +│ ├── seededWallets.ts Typed wrappers with declared balances +│ ├── networkRoutes.ts page.route() helpers (mempool, VP, eth) +│ ├── test.ts Playwright test.extend with fixtures +│ └── index.ts Public barrel - tests import from here +├── pages/ Page objects +│ ├── AppShell.ts +│ ├── Dashboard.ts +│ ├── DepositModal.ts +│ └── WithdrawModal.ts +└── *.spec.ts Specs +``` + +## Running tests + +Playwright spins up the vault dev server itself (see +`playwright.config.ts` `webServer`). Tests intercept all network calls +via `page.route()`, so no separate backend is required. + +```bash +pnpm --filter vault run test:e2e # headless +pnpm --filter vault run test:e2e:headed # with browser UI +pnpm --filter vault run test:e2e:ui # Playwright UI mode +pnpm --filter vault run test:e2e:report # open last HTML report +``` + +## Manual dev: standalone mock backends + +Use this when you want to point a regular browser at the vault dApp +with deterministic backend responses (no Playwright). + +```bash +pnpm --filter vault run e2e:env # starts the four mock listeners +NEXT_PUBLIC_E2E_MODE=1 pnpm --filter vault run dev +``` + +The stubs (defined in `services/vault/scripts/e2e-env.mjs`) listen on +the same ports `playwright.config.ts` uses: + +| Port | Purpose | +| ---: | ------------------------------------ | +| 9996 | mempool API (`/mempool/...`) | +| 9997 | eth JSON-RPC (`POST /rpc`) | +| 9998 | vault-provider proxy (`/vp-health`, `/rpc/{vp}`) | +| 9999 | GraphQL (`POST /graphql`) | + +The standalone stubs return generic defaults. Per-test customisation +goes through the route handlers in `networkRoutes.ts`, not the +standalone process. + +## Writing a test + +```ts +import { + test, + expect, + mockMempoolForSeededBtcWallet, + mockVpProxy, +} from "./fixtures"; + +test("dashboard reflects seeded BTC balance", async ({ + page, + seededBtcWallet, + installWallets, + appShell, + dashboard, +}) => { + const wallet = seededBtcWallet({ amount: 250_000n }); + await mockMempoolForSeededBtcWallet(page, wallet); + await mockVpProxy(page); + await installWallets({ btc: wallet }); + + await appShell.goto(); + await expect(dashboard.collateralSectionHeading).toBeVisible(); +}); +``` + +## Conventions + +- **One fixture, one concern.** `seededBtcWallet` only describes the + wallet; `mockMempoolForSeededBtcWallet` only wires its mempool + responses. Compose them at the test site so each test reads top to + bottom. +- **No magic defaults that hide failures.** A test that forgets to + install a route handler should fail loud, not silently fall through + to a default empty response. The route helpers prefer 501s over + silent 200s for unhandled paths. +- **Selectors prefer role + accessible name.** Where role is not + exposed, fall back to a stable `data-testid`. Adding new testids in + source (kebab-case, feature-specific) is preferred over CSS + selectors. +- **Each test starts clean.** Playwright tears down `page` between + tests; route handlers and `window.__BABYLON_E2E_WALLETS__` reset + automatically. Do not rely on prior test state. + +## Override pattern + +`page.route()` handlers register in order; the **last** matching +handler wins. Per-test overrides therefore go after fixture handlers: + +```ts +await mockMempoolForSeededBtcWallet(page, wallet); // default +await page.route("**/v1/fees/recommended", (route) => // override + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ fastestFee: 50, halfHourFee: 40, hourFee: 30, economyFee: 20, minimumFee: 10 }), + }), +); +``` + +## CI + +The suite runs under the standard playwright runner. CI parity is +trivial because there's nothing external to provision: GitHub Actions +runs `pnpm --filter vault run test:e2e` and the same in-test route +handlers fire. Sharding/parallelism lands with #1602. diff --git a/services/vault/e2e/fixtures-smoke.spec.ts b/services/vault/e2e/fixtures-smoke.spec.ts new file mode 100644 index 000000000..703e3ce65 --- /dev/null +++ b/services/vault/e2e/fixtures-smoke.spec.ts @@ -0,0 +1,84 @@ +/** + * Smoke spec for the e2e fixture scaffold introduced in #1589. + * + * Verifies: + * - the typed seeded-wallet factories return values that satisfy the + * mempool wire shape the dApp's `useUTXOs` consumes; + * - `installWallets` writes a sentinel to `window.__BABYLON_E2E_WALLETS__` + * before navigation completes; + * - the page-object scaffold reaches the running app (AppShell on `/`). + * + * The deeper page interactions (clicking through the deposit modal, + * asserting a position appears) belong to the per-flow tickets. + */ + +import { + E2E_WALLETS_GLOBAL, + expect, + mockMempoolForSeededBtcWallet, + mockVpProxy, + test, +} from "./fixtures"; + +test("seededBtcWallet exposes mempool-wire payloads that sum to the seeded amount", ({ + seededBtcWallet, +}) => { + const wallet = seededBtcWallet({ amount: 250_000n }); + expect(wallet.balanceSats).toBe(250_000n); + const totalValue = wallet.mempoolUtxos.reduce((s, u) => s + u.value, 0); + expect(BigInt(totalValue)).toBe(250_000n); + expect(wallet.mempoolAddressInfo.isvalid).toBe(true); + // P2TR scriptPubKey: 0x5120 (OP_1 push-32) + 32-byte x-only pubkey (64 hex) + expect(wallet.mempoolAddressInfo.scriptPubKey).toMatch(/^5120[0-9a-f]{64}$/); +}); + +test("seededBtcWallet rejects utxoSplit that doesn't sum to amount", ({ + seededBtcWallet, +}) => { + expect(() => + seededBtcWallet({ amount: 100n, utxoSplit: [40n, 50n] }), + ).toThrow(/sum to 90n, expected 100n/); +}); + +test("seededEthWallet exposes balanceWeiHex as a valid quantity", ({ + seededEthWallet, +}) => { + const wallet = seededEthWallet({ balanceWei: 5n * 10n ** 18n }); + expect(wallet.balanceWeiHex).toBe(`0x${(5n * 10n ** 18n).toString(16)}`); +}); + +test("installWallets sets the e2e wallet global before navigation", async ({ + page, + seededBtcWallet, + installWallets, +}) => { + const wallet = seededBtcWallet({ amount: 100_000n }); + await mockMempoolForSeededBtcWallet(page, wallet); + await mockVpProxy(page); + await installWallets({ btc: wallet }); + await page.goto("/"); + + const installed = await page.evaluate((name) => { + const w = window as unknown as Record< + string, + { btc?: { kind?: string; address?: string } } | undefined + >; + return w[name]; + }, E2E_WALLETS_GLOBAL); + + expect(installed?.btc?.kind).toBe("seeded-btc"); + expect(installed?.btc?.address).toBe(wallet.address); +}); + +test("AppShell page object reaches the app shell at /", async ({ + page, + appShell, +}) => { + await mockVpProxy(page); + await appShell.goto(); + // The shell renders the connect entry point even before a wallet is + // injected. We don't assert visibility (the unconnected state may + // show a different label depending on copy) - reaching the page + // without a navigation error is enough for the smoke check. + await expect(page).toHaveURL(/\/$/); +}); diff --git a/services/vault/e2e/fixtures/index.ts b/services/vault/e2e/fixtures/index.ts index 4f64beae8..12cc5bf41 100644 --- a/services/vault/e2e/fixtures/index.ts +++ b/services/vault/e2e/fixtures/index.ts @@ -10,6 +10,23 @@ export type { MockEthWallet, MockEthWalletOptions, } from "./mockEthWallet"; +export { + mockEthRpc, + mockGraphql, + mockMempoolForSeededBtcWallet, + mockVpProxy, +} from "./networkRoutes"; +export { seededBtcWallet, seededEthWallet } from "./seededWallets"; +export type { + SeededBtcWallet, + SeededBtcWalletOptions, + SeededEthWallet, + SeededEthWalletOptions, + SeededMempoolAddressInfo, + SeededMempoolUtxo, +} from "./seededWallets"; +export { expect, test } from "./test"; +export type { VaultE2EFixtures } from "./test"; export { E2E_WALLETS_GLOBAL, clearInjectedWallets, diff --git a/services/vault/e2e/fixtures/networkRoutes.ts b/services/vault/e2e/fixtures/networkRoutes.ts new file mode 100644 index 000000000..54824d0eb --- /dev/null +++ b/services/vault/e2e/fixtures/networkRoutes.ts @@ -0,0 +1,172 @@ +/** + * Playwright route handlers for the network surfaces the vault dApp + * hits at runtime: mempool.space, the vault-provider proxy + * (`NEXT_PUBLIC_TBV_VP_PROXY_URL`), and the eth RPC + * (`NEXT_PUBLIC_ETH_RPC_URL`). Centralising them keeps tests free of + * URL literals and ensures the route patterns track playwright config. + * + * Each helper is composable: call several on the same `page` to layer + * behaviour. The last-registered handler wins for overlapping patterns, + * which mirrors Playwright's route precedence. + */ + +import type { Page, Route } from "@playwright/test"; + +import type { VpHealthSnapshot } from "../../src/types/vpHealth"; + +import type { + SeededBtcWallet, + SeededMempoolAddressInfo, + SeededMempoolUtxo, +} from "./seededWallets"; + +const DEFAULT_NETWORK_FEES = { + fastestFee: 5, + halfHourFee: 4, + hourFee: 3, + economyFee: 2, + minimumFee: 1, +}; + +function jsonResponse( + route: Route, + body: unknown, + status = 200, +): Promise { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** + * Mempool routes for a single seeded BTC address. + * + * Wires `/address/{addr}/utxo`, `/v1/validate-address/{addr}`, and + * `/v1/fees/recommended` to return the wallet's seeded payloads. Other + * mempool paths fall through unhandled, which surfaces as test + * failures - tests that need more mempool behaviour must extend this + * helper rather than silently 200 everything. + */ +export async function mockMempoolForSeededBtcWallet( + page: Page, + wallet: Pick< + SeededBtcWallet, + "address" | "mempoolUtxos" | "mempoolAddressInfo" + >, + feeOverrides?: Partial, +): Promise { + const fees = { ...DEFAULT_NETWORK_FEES, ...feeOverrides }; + // The dApp builds URLs from the network config base; matching by + // path suffix keeps the route portable across signet/mainnet bases. + await page.route(`**/address/${wallet.address}/utxo`, (route) => + jsonResponse(route, wallet.mempoolUtxos satisfies SeededMempoolUtxo[]), + ); + await page.route(`**/v1/validate-address/${wallet.address}`, (route) => + jsonResponse( + route, + wallet.mempoolAddressInfo satisfies SeededMempoolAddressInfo, + ), + ); + await page.route("**/v1/fees/recommended", (route) => + jsonResponse(route, fees), + ); +} + +/** + * VP proxy routes. Returns the supplied health snapshots from + * `/vp-health` and 501s any RPC forwarder call unless a per-VP + * handler is provided. Tests that exercise VP-specific RPC must pass + * a `rpcHandler` that switches on `vpAddress`. + */ +export async function mockVpProxy( + page: Page, + options: { + healthSnapshots?: VpHealthSnapshot[]; + rpcHandler?: (vpAddress: string, body: unknown) => unknown; + } = {}, +): Promise { + const snapshots = options.healthSnapshots ?? []; + await page.route("**/vp-health", (route) => jsonResponse(route, snapshots)); + await page.route("**/rpc/*", async (route) => { + if (!options.rpcHandler) { + return jsonResponse( + route, + { error: "no VP rpc handler installed for this test" }, + 501, + ); + } + const url = new URL(route.request().url()); + const vpAddress = url.pathname.split("/").pop() ?? ""; + const requestBody = route.request().postDataJSON?.() ?? null; + const body = options.rpcHandler(vpAddress, requestBody); + await jsonResponse(route, body); + }); +} + +/** + * Mock the ETH JSON-RPC endpoint. The dApp issues `eth_chainId`, + * `eth_call`, `eth_getBalance`, etc. to `NEXT_PUBLIC_ETH_RPC_URL`. The + * default handler answers `eth_chainId` with sepolia and returns null + * for everything else - tests that need contract reads must supply a + * handler keyed on `(method, params)`. + */ +export async function mockEthRpc( + page: Page, + handler?: (method: string, params: unknown[]) => unknown, +): Promise { + await page.route("**/rpc", async (route) => { + const request = route.request(); + if (request.method() !== "POST") { + return jsonResponse(route, { error: "method not allowed" }, 405); + } + const body = (request.postDataJSON?.() ?? {}) as { + id?: number | string | null; + method?: string; + params?: unknown[]; + }; + const method = body.method ?? ""; + const params = body.params ?? []; + let result: unknown = null; + if (handler) { + result = handler(method, params); + } else if (method === "eth_chainId") { + result = "0xaa36a7"; + } + await jsonResponse(route, { + jsonrpc: "2.0", + id: body.id ?? 1, + result, + }); + }); +} + +/** + * Mock the GraphQL endpoint with a request-shape-aware handler. The + * vault app uses `graphql-request` so each request is `POST` with a + * JSON body `{ query, variables, operationName }`. The handler returns + * the data field; errors propagate verbatim. + */ +export async function mockGraphql( + page: Page, + handler: (operation: { + operationName?: string; + query: string; + variables: Record; + }) => { data?: unknown; errors?: unknown[] }, +): Promise { + await page.route("**/graphql", async (route) => { + const body = (route.request().postDataJSON?.() ?? {}) as { + operationName?: string; + query?: string; + variables?: Record; + }; + const response = handler({ + operationName: body.operationName, + query: body.query ?? "", + variables: body.variables ?? {}, + }); + await jsonResponse(route, response); + }); +} diff --git a/services/vault/e2e/fixtures/seededWallets.ts b/services/vault/e2e/fixtures/seededWallets.ts new file mode 100644 index 000000000..9930bb274 --- /dev/null +++ b/services/vault/e2e/fixtures/seededWallets.ts @@ -0,0 +1,153 @@ +/** + * Typed wallet "fixtures" with declared on-chain state. + * + * `seededBtcWallet({ amount })` wraps `createMockBtcWallet` and + * additionally publishes the mempool-API wire payloads a route handler + * needs to return so the dApp's `useUTXOs` resolves to `amount`. The + * wallet does NOT touch the network: a route handler (see + * `networkRoutes.ts`) intercepts the calls. + * + * `seededEthWallet({ balanceWei })` wraps `createMockEthWallet` and + * exposes the balance so route handlers / contract-read mocks can + * answer consistently. + * + * Mock-first by design: nothing here boots a chain. The seed surfaces + * deterministic state at the existing HTTP/RPC interception points. + */ + +import { + createMockBtcWallet, + type MockBtcWallet, + type MockBtcWalletOptions, +} from "./mockBtcWallet"; +import { + createMockEthWallet, + type MockEthWallet, + type MockEthWalletOptions, +} from "./mockEthWallet"; + +const DEFAULT_BTC_ADDRESS = "tb1qce0n0rv27dwx37dfvhxaaly4lnwelqjuqywvka"; + +/** Wire payload from `GET /api/address/{addr}/utxo`. */ +export interface SeededMempoolUtxo { + txid: string; + vout: number; + value: number; + status: { confirmed: boolean }; +} + +/** Wire payload from `GET /api/v1/validate-address/{addr}`. */ +export interface SeededMempoolAddressInfo { + isvalid: boolean; + scriptPubKey: string; +} + +export interface SeededBtcWalletOptions extends MockBtcWalletOptions { + /** Total seeded balance in satoshis. Must be > 0n. */ + amount: bigint; + /** + * Optional UTXO split. Default: one UTXO holding the full amount. + * Multi-UTXO tests exercise selection logic and must pass values that + * sum to `amount`. + */ + utxoSplit?: bigint[]; +} + +export interface SeededBtcWallet extends MockBtcWallet { + /** Address the seeded balance is tied to. */ + address: string; + /** Sum of `mempoolUtxos`. Equals `options.amount`. */ + balanceSats: bigint; + /** Wire payload route handler returns from `/address/{addr}/utxo`. */ + mempoolUtxos: SeededMempoolUtxo[]; + /** Wire payload route handler returns from `/v1/validate-address/{addr}`. */ + mempoolAddressInfo: SeededMempoolAddressInfo; +} + +export interface SeededEthWalletOptions extends MockEthWalletOptions { + /** Seeded native ETH balance in wei. Must be >= 0n. */ + balanceWei: bigint; +} + +export interface SeededEthWallet extends MockEthWallet { + /** Native balance in wei (same value RPC handlers will return). */ + balanceWei: bigint; + /** Hex-quantity form for direct use in RPC handlers. */ + balanceWeiHex: `0x${string}`; +} + +function deriveTxid(index: number): string { + return `ee${"00".repeat(30)}${index.toString(16).padStart(4, "0")}`; +} + +function deriveScriptPubKey(address: string): string { + // Placeholder P2TR scriptPubKey: `5120` (OP_1 + push-32) plus a + // synthetic 32-byte x-only pubkey derived from the address. + // assertValidScriptPubKey requires hex bytes, so we hash the address + // into hex rather than reusing the bech32 characters directly. The + // value is not a real signing key - tests that exercise PSBT + // signing must override per-call via the wallet `script` API. + let hash = 0n; + for (const ch of address) { + hash = (hash * 1315423911n) ^ BigInt(ch.charCodeAt(0)); + } + const hex = hash.toString(16).padStart(64, "0").slice(-64); + return `5120${hex}`; +} + +function buildUtxos( + amount: bigint, + split: bigint[] | undefined, +): SeededMempoolUtxo[] { + const values = split ?? [amount]; + const total = values.reduce((s, v) => s + v, 0n); + if (total !== amount) { + throw new Error( + `seededBtcWallet: utxoSplit values sum to ${total}n, expected ${amount}n`, + ); + } + return values.map((value, index) => ({ + txid: deriveTxid(index), + vout: 0, + value: Number(value), + status: { confirmed: true }, + })); +} + +export function seededBtcWallet( + options: SeededBtcWalletOptions, +): SeededBtcWallet { + if (options.amount <= 0n) { + throw new Error("seededBtcWallet: amount must be > 0n"); + } + const wallet = createMockBtcWallet(options); + const address = options.address ?? DEFAULT_BTC_ADDRESS; + return { + ...wallet, + address, + balanceSats: options.amount, + mempoolUtxos: buildUtxos(options.amount, options.utxoSplit), + mempoolAddressInfo: { + isvalid: true, + scriptPubKey: deriveScriptPubKey(address), + }, + }; +} + +function toQuantityHex(value: bigint): `0x${string}` { + if (value < 0n) { + throw new Error("seededEthWallet: balanceWei must be >= 0n"); + } + return `0x${value.toString(16)}`; +} + +export function seededEthWallet( + options: SeededEthWalletOptions, +): SeededEthWallet { + const wallet = createMockEthWallet(options); + return { + ...wallet, + balanceWei: options.balanceWei, + balanceWeiHex: toQuantityHex(options.balanceWei), + }; +} diff --git a/services/vault/e2e/fixtures/test.ts b/services/vault/e2e/fixtures/test.ts new file mode 100644 index 000000000..960a7c292 --- /dev/null +++ b/services/vault/e2e/fixtures/test.ts @@ -0,0 +1,130 @@ +/* eslint-disable react-hooks/rules-of-hooks -- + * Playwright's fixture API is `(deps, use) => ...`. The `use` callback + * is unrelated to React's `use` hook but the rule cannot tell them + * apart. This file is exclusively Playwright fixture wiring. + */ + +/** + * Playwright `test` extended with the vault e2e fixtures. + * + * Tests import `test` and `expect` from here instead of + * `@playwright/test`. Each fixture is opt-in: wallets, page objects, + * and route handlers are only constructed when a test names them. + * + * Page objects are thin wrappers around Playwright locators - they + * navigate and click, they do not assert. Tests own assertions. + * + * Each test starts clean: Playwright tears down `page` between tests, + * so `window.__BABYLON_E2E_WALLETS__` and any `page.route()` handlers + * reset automatically. There is no shared in-memory state to reset + * between tests. + */ + +import { test as base, expect, type Page } from "@playwright/test"; + +import { AppShell } from "../pages/AppShell"; +import { Dashboard } from "../pages/Dashboard"; +import { DepositModal } from "../pages/DepositModal"; +import { WithdrawModal } from "../pages/WithdrawModal"; + +import { + seededBtcWallet, + seededEthWallet, + type SeededBtcWallet, + type SeededBtcWalletOptions, + type SeededEthWallet, + type SeededEthWalletOptions, +} from "./seededWallets"; +import { E2E_WALLETS_GLOBAL } from "./walletInjection"; + +export interface VaultE2EFixtures { + /** Build a seeded BTC wallet with a declared balance. */ + seededBtcWallet: (options: SeededBtcWalletOptions) => SeededBtcWallet; + /** Build a seeded ETH wallet with a declared balance. */ + seededEthWallet: (options: SeededEthWalletOptions) => SeededEthWallet; + /** + * Install the given wallets on `window.__BABYLON_E2E_WALLETS__` + * before the next navigation. Omit fields to skip. + */ + installWallets: (wallets: { + btc?: SeededBtcWallet | null; + eth?: SeededEthWallet | null; + }) => Promise; + appShell: AppShell; + dashboard: Dashboard; + depositModal: DepositModal; + withdrawModal: WithdrawModal; +} + +interface WalletSentinel { + kind: "seeded-btc" | "seeded-eth"; + address?: string; +} + +interface WalletSentinelPayload { + btc?: WalletSentinel; + eth?: WalletSentinel; +} + +async function installWalletsOnPage( + page: Page, + globalName: string, + payload: WalletSentinelPayload, +): Promise { + // The full mocks contain closures that don't survive structured-clone + // across the Node/browser boundary, so we install a JSON-only + // sentinel here. Reconstructing the live mock provider page-side is + // the single-vault deposit happy-path ticket's responsibility + // (#1592). For #1589 the install proves the bridge fires before + // navigation - enough to unblock per-flow tickets. + await page.addInitScript( + ({ globalName: name, sentinel }) => { + (window as unknown as Record)[name] = sentinel; + }, + { globalName, sentinel: payload }, + ); +} + +function toSentinel( + wallet: SeededBtcWallet | SeededEthWallet | null | undefined, + kind: "seeded-btc" | "seeded-eth", +): WalletSentinel | undefined { + if (!wallet) return undefined; + if (kind === "seeded-btc") { + return { kind, address: (wallet as SeededBtcWallet).address }; + } + return { kind, address: (wallet as SeededEthWallet).account.address }; +} + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern -- Playwright fixture signature requires destructure even with no deps + seededBtcWallet: async ({}, use) => { + await use(seededBtcWallet); + }, + // eslint-disable-next-line no-empty-pattern -- see above + seededEthWallet: async ({}, use) => { + await use(seededEthWallet); + }, + installWallets: async ({ page }, use) => { + await use(async (wallets) => { + await installWalletsOnPage(page, E2E_WALLETS_GLOBAL, { + btc: toSentinel(wallets.btc, "seeded-btc"), + eth: toSentinel(wallets.eth, "seeded-eth"), + }); + }); + }, + appShell: async ({ page }, use) => { + await use(new AppShell(page)); + }, + dashboard: async ({ page }, use) => { + await use(new Dashboard(page)); + }, + depositModal: async ({ page }, use) => { + await use(new DepositModal(page)); + }, + withdrawModal: async ({ page }, use) => { + await use(new WithdrawModal(page)); + }, +}); + +export { expect }; diff --git a/services/vault/e2e/pages/AppShell.ts b/services/vault/e2e/pages/AppShell.ts new file mode 100644 index 000000000..f6baa8516 --- /dev/null +++ b/services/vault/e2e/pages/AppShell.ts @@ -0,0 +1,52 @@ +/** + * Page object for the vault dApp's persistent shell: top nav, connect + * button, theme toggle, and the route-level entry points (Applications + * tab, Activity tab, Deposit CTA). + * + * Selectors prefer role+name with COPY constants so that copy edits + * keep tests passing. Where a stable data-testid exists in source, it + * wins. Adding new selectors here is the place to land + * stability-improving testids in the React tree. + */ + +import type { Locator, Page } from "@playwright/test"; + +export class AppShell { + constructor(public readonly page: Page) {} + + async goto(path = "/"): Promise { + await this.page.goto(path); + } + + get connectButton(): Locator { + // Connect button in the top nav (core-ui ConnectButton). Matches + // any button whose accessible name starts with "Connect". + return this.page.getByRole("button", { name: /^Connect/i }); + } + + get applicationsTab(): Locator { + return this.page.getByRole("link", { name: /Applications/i }); + } + + get activityTab(): Locator { + return this.page.getByRole("link", { name: /Activity/i }); + } + + get depositCta(): Locator { + // The persistent "Deposit BTC" CTA rendered in RootLayout once a + // wallet is connected. Distinct from the in-modal "Deposit" submit. + return this.page.getByRole("button", { name: /^Deposit BTC$/i }); + } + + async openActivity(): Promise { + await this.activityTab.click(); + } + + async openApplications(): Promise { + await this.applicationsTab.click(); + } + + async openDeposit(): Promise { + await this.depositCta.click(); + } +} diff --git a/services/vault/e2e/pages/Dashboard.ts b/services/vault/e2e/pages/Dashboard.ts new file mode 100644 index 000000000..a9cce55cb --- /dev/null +++ b/services/vault/e2e/pages/Dashboard.ts @@ -0,0 +1,30 @@ +/** + * Page object for the post-connect dashboard at `/`. Surfaces the + * collateral section (active vault positions), the deposit CTA, and + * per-vault expand / withdraw entry points. + */ + +import type { Locator, Page } from "@playwright/test"; + +export class Dashboard { + constructor(public readonly page: Page) {} + + async goto(): Promise { + await this.page.goto("/"); + } + + get collateralSectionHeading(): Locator { + return this.page.getByRole("heading", { name: /Collateral/i }); + } + + get withdrawButton(): Locator { + // Per-row withdraw entry inside the collateral list. Tests with + // multiple positions should narrow via `vaultRow(...)` first. + return this.page.getByRole("button", { name: /^Withdraw/i }); + } + + /** Locator for a vault row matched by visible text (vault address / id). */ + vaultRow(matcher: string | RegExp): Locator { + return this.page.getByRole("row", { name: matcher }); + } +} diff --git a/services/vault/e2e/pages/DepositModal.ts b/services/vault/e2e/pages/DepositModal.ts new file mode 100644 index 000000000..0aa9c9c47 --- /dev/null +++ b/services/vault/e2e/pages/DepositModal.ts @@ -0,0 +1,67 @@ +/** + * Page object for the deposit flow. The flow renders as a + * `` from core-ui and walks through: + * + * 1. DepositForm - amount + vault provider selection + * 2. SignContent - PSBT signing with the connected BTC wallet + * 3. ProgressView - broadcast + activation polling + * + * Per the per-flow tickets (#1592, #1593, #1594) each phase will + * acquire dedicated assertion helpers; this object exposes the minimum + * locators a happy-path test needs to drive each step. + */ + +import type { Locator, Page } from "@playwright/test"; + +export class DepositModal { + constructor(public readonly page: Page) {} + + get dialog(): Locator { + // FullScreenDialog renders the Radix dialog primitive with + // role="dialog" + aria-modal. Scoping all subsequent locators to + // the dialog avoids accidental matches against shell-level CTAs + // of the same name. + return this.page.getByRole("dialog"); + } + + get amountInput(): Locator { + // AmountSlider renders a numeric ``; spinbutton covers both + // the input and stepper UI. + return this.dialog.getByRole("spinbutton").first(); + } + + get maxButton(): Locator { + return this.dialog.getByRole("button", { name: /^Max$/i }); + } + + get vaultProviderSelect(): Locator { + // Falls back to the placeholder text when the combobox role isn't + // exposed by the underlying core-ui Select. + return this.dialog + .getByRole("combobox") + .or(this.dialog.getByPlaceholder(/Select Vault Provider/i)); + } + + get submitButton(): Locator { + return this.dialog.getByRole("button", { name: /^(Deposit|Next)$/i }); + } + + get signButton(): Locator { + return this.dialog.getByRole("button", { name: /^Sign/i }); + } + + get closeButton(): Locator { + return this.dialog.getByRole("button", { + name: /Close|Done|Continue later/i, + }); + } + + async fillAmount(btc: number | string): Promise { + await this.amountInput.fill(String(btc)); + } + + async pickVaultProvider(name: string | RegExp): Promise { + await this.vaultProviderSelect.click(); + await this.page.getByRole("option", { name }).click(); + } +} diff --git a/services/vault/e2e/pages/WithdrawModal.ts b/services/vault/e2e/pages/WithdrawModal.ts new file mode 100644 index 000000000..9a28094ea --- /dev/null +++ b/services/vault/e2e/pages/WithdrawModal.ts @@ -0,0 +1,38 @@ +/** + * Page object for the withdraw / redemption flow. Renders as a + * `` triggered from the dashboard collateral row. + * + * Exposes the review-screen confirm button plus the two known + * health-factor warning surfaces, which already have stable testids + * in source (`withdraw-hf-block-warning`, `withdraw-hf-at-risk-warning`). + */ + +import type { Locator, Page } from "@playwright/test"; + +export class WithdrawModal { + constructor(public readonly page: Page) {} + + get dialog(): Locator { + return this.page.getByRole("dialog"); + } + + get amountInput(): Locator { + return this.dialog.getByRole("spinbutton").first(); + } + + get confirmButton(): Locator { + return this.dialog.getByRole("button", { name: /^Confirm$/i }); + } + + get closeButton(): Locator { + return this.dialog.getByRole("button", { name: /^(Close|Done)$/i }); + } + + get healthFactorBlockWarning(): Locator { + return this.page.getByTestId("withdraw-hf-block-warning"); + } + + get healthFactorAtRiskWarning(): Locator { + return this.page.getByTestId("withdraw-hf-at-risk-warning"); + } +} diff --git a/services/vault/e2e/pages/index.ts b/services/vault/e2e/pages/index.ts new file mode 100644 index 000000000..78fea9f1c --- /dev/null +++ b/services/vault/e2e/pages/index.ts @@ -0,0 +1,4 @@ +export { AppShell } from "./AppShell"; +export { Dashboard } from "./Dashboard"; +export { DepositModal } from "./DepositModal"; +export { WithdrawModal } from "./WithdrawModal"; diff --git a/services/vault/package.json b/services/vault/package.json index 0b8f6c671..19da756fd 100644 --- a/services/vault/package.json +++ b/services/vault/package.json @@ -22,7 +22,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "e2e:env": "node scripts/e2e-env.mjs" }, "dependencies": { "@babylonlabs-io/core-ui": "workspace:*", diff --git a/services/vault/playwright.config.ts b/services/vault/playwright.config.ts index 981f04324..03206e8a1 100644 --- a/services/vault/playwright.config.ts +++ b/services/vault/playwright.config.ts @@ -14,6 +14,12 @@ const MOCK_ENV_VARS = { NEXT_PUBLIC_TBV_GRAPHQL_ENDPOINT: "http://localhost:9999/graphql", NEXT_PUBLIC_TBV_VP_PROXY_URL: "http://localhost:9998", NEXT_PUBLIC_ETH_RPC_URL: "http://localhost:9997/rpc", + // Pinned mempool base so route handlers in + // `services/vault/e2e/fixtures/networkRoutes.ts` can match + // deterministic paths. Without this the dApp falls through to a + // signet/mainnet default and tests would have to intercept the live + // hostname. + NEXT_PUBLIC_MEMPOOL_API: "http://localhost:9996/mempool", NEXT_PUBLIC_REOWN_PROJECT_ID: "test-project-id-12345", NEXT_PUBLIC_SENTRY_DSN: "https://test@o12345.ingest.sentry.io/12345", NEXT_PUBLIC_SIDECAR_API_URL: "http://localhost:8092", @@ -27,6 +33,13 @@ const MOCK_ENV_VARS = { export default defineConfig({ testDir: path.join(__dirname, "e2e"), + // Match only Playwright specs. The fixtures themselves have + // colocated vitest unit tests under `e2e/fixtures/__tests__/`; those + // are run by vitest (`pnpm test`), not Playwright. Loading them here + // double-instantiates @vitest/expect alongside @playwright/test's + // expect and crashes discovery with a `Symbol($$jest-matchers-object)` + // collision. + testMatch: "**/*.spec.ts", fullyParallel: false, forbidOnly: false, retries: 2, diff --git a/services/vault/scripts/e2e-env.mjs b/services/vault/scripts/e2e-env.mjs new file mode 100644 index 000000000..01423c0cf --- /dev/null +++ b/services/vault/scripts/e2e-env.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * `pnpm --filter vault run e2e:env` + * + * Standalone process boot for the e2e mock backends. Useful for manual + * dev when you want to point a local browser at the vault dApp with + * deterministic backend responses (vault-provider proxy + mempool + + * eth-rpc + graphql). + * + * Playwright tests do NOT boot this script. They install per-test + * route handlers via `services/vault/e2e/fixtures/networkRoutes.ts`, + * which intercepts the same URLs at the page-context layer. + * + * Boots HTTP listeners on the ports declared in playwright.config.ts: + * - 9998 vault-provider proxy (vp-health, rpc/{addr}) + * - 9997 eth rpc (POST /rpc) + * - 9996 mempool api (everything under /mempool) + * - 9999 graphql (POST /graphql) + * + * Ctrl-C tears all four down cleanly. + */ + +import http from "node:http"; + +const PORTS = { + vp: 9998, + ethRpc: 9997, + mempool: 9996, + graphql: 9999, +}; + +const DEFAULT_BTC_ADDRESS = "tb1qce0n0rv27dwx37dfvhxaaly4lnwelqjuqywvka"; +const DEFAULT_BALANCE_SATS = 100_000_000; +const DEFAULT_SCRIPT_PUBKEY = `5120${DEFAULT_BTC_ADDRESS + .slice(-40) + .padStart(40, "0")}`; + +function jsonResponse(res, body, status = 200) { + res.writeHead(status, { + "content-type": "application/json", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": "content-type", + }); + res.end(JSON.stringify(body)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + try { + resolve(raw.length ? JSON.parse(raw) : {}); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function handlePreflight(req, res) { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": "content-type", + }); + res.end(); + return true; + } + return false; +} + +const vpServer = http.createServer((req, res) => { + if (handlePreflight(req, res)) return; + const url = new URL(req.url ?? "/", `http://localhost:${PORTS.vp}`); + if (url.pathname === "/vp-health") { + jsonResponse(res, []); + return; + } + if (url.pathname.startsWith("/rpc/")) { + jsonResponse(res, { + jsonrpc: "2.0", + id: 1, + result: null, + }); + return; + } + jsonResponse(res, { error: "not found" }, 404); +}); + +const ethServer = http.createServer(async (req, res) => { + if (handlePreflight(req, res)) return; + const url = new URL(req.url ?? "/", `http://localhost:${PORTS.ethRpc}`); + if (url.pathname !== "/rpc" || req.method !== "POST") { + jsonResponse(res, { error: "not found" }, 404); + return; + } + let body; + try { + body = await readBody(req); + } catch { + jsonResponse(res, { error: "invalid json" }, 400); + return; + } + const method = body.method ?? ""; + let result = null; + if (method === "eth_chainId") { + result = "0xaa36a7"; + } else if (method === "eth_blockNumber") { + result = "0x1"; + } else if (method === "eth_getBalance") { + result = "0x0"; + } + jsonResponse(res, { jsonrpc: "2.0", id: body.id ?? 1, result }); +}); + +const mempoolServer = http.createServer((req, res) => { + if (handlePreflight(req, res)) return; + const url = new URL(req.url ?? "/", `http://localhost:${PORTS.mempool}`); + const path = url.pathname.replace(/^\/mempool/, ""); + if (path === `/address/${DEFAULT_BTC_ADDRESS}/utxo`) { + jsonResponse(res, [ + { + txid: `ee${"00".repeat(30)}0000`, + vout: 0, + value: DEFAULT_BALANCE_SATS, + status: { confirmed: true }, + }, + ]); + return; + } + if (path === `/v1/validate-address/${DEFAULT_BTC_ADDRESS}`) { + jsonResponse(res, { isvalid: true, scriptPubKey: DEFAULT_SCRIPT_PUBKEY }); + return; + } + if (path === "/v1/fees/recommended") { + jsonResponse(res, { + fastestFee: 5, + halfHourFee: 4, + hourFee: 3, + economyFee: 2, + minimumFee: 1, + }); + return; + } + jsonResponse(res, { error: "not found" }, 404); +}); + +const graphqlServer = http.createServer(async (req, res) => { + if (handlePreflight(req, res)) return; + if (req.method !== "POST") { + jsonResponse(res, { error: "method not allowed" }, 405); + return; + } + jsonResponse(res, { data: {} }); +}); + +function listen(server, port, label) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`[e2e:env] ${label} listening on http://localhost:${port}`); + resolve(); + }); + }); +} + +async function main() { + await Promise.all([ + listen(vpServer, PORTS.vp, "vault-provider-proxy"), + listen(ethServer, PORTS.ethRpc, "eth-rpc"), + listen(mempoolServer, PORTS.mempool, "mempool"), + listen(graphqlServer, PORTS.graphql, "graphql"), + ]); + // eslint-disable-next-line no-console + console.log("[e2e:env] all stubs up. Ctrl-C to stop."); +} + +function shutdown() { + // eslint-disable-next-line no-console + console.log("\n[e2e:env] shutting down..."); + for (const server of [vpServer, ethServer, mempoolServer, graphqlServer]) { + server.close(); + } + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error("[e2e:env] failed to start", err); + process.exit(1); +}); diff --git a/services/vault/tsconfig.lib.json b/services/vault/tsconfig.lib.json index 8f28f816d..87b9f4bba 100644 --- a/services/vault/tsconfig.lib.json +++ b/services/vault/tsconfig.lib.json @@ -24,5 +24,5 @@ "@/*": ["./src/*"] } }, - "include": ["src", "e2e/fixtures", "sentry.client.config.ts"] + "include": ["src", "e2e/fixtures", "e2e/pages", "sentry.client.config.ts"] } From 56ff8e81452e27427c010725055f17ccc1fb1d0c Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Mon, 18 May 2026 10:43:15 +0100 Subject: [PATCH 2/3] fix(vault): address comments --- services/vault/e2e/README.md | 4 +- services/vault/e2e/fixtures-smoke.spec.ts | 15 +++--- services/vault/e2e/fixtures/index.ts | 1 + services/vault/e2e/fixtures/networkRoutes.ts | 41 ++++++++++++++++- services/vault/e2e/fixtures/seededWallets.ts | 48 +++++++++++++++----- services/vault/e2e/fixtures/test.ts | 19 +++++--- services/vault/e2e/pages/Dashboard.ts | 16 +++++-- services/vault/scripts/e2e-env.mjs | 9 ++-- 8 files changed, 117 insertions(+), 36 deletions(-) diff --git a/services/vault/e2e/README.md b/services/vault/e2e/README.md index 73ccda867..bbf4e9402 100644 --- a/services/vault/e2e/README.md +++ b/services/vault/e2e/README.md @@ -83,14 +83,14 @@ import { test("dashboard reflects seeded BTC balance", async ({ page, seededBtcWallet, - installWallets, + installWalletSentinel, appShell, dashboard, }) => { const wallet = seededBtcWallet({ amount: 250_000n }); await mockMempoolForSeededBtcWallet(page, wallet); await mockVpProxy(page); - await installWallets({ btc: wallet }); + await installWalletSentinel({ btc: wallet }); await appShell.goto(); await expect(dashboard.collateralSectionHeading).toBeVisible(); diff --git a/services/vault/e2e/fixtures-smoke.spec.ts b/services/vault/e2e/fixtures-smoke.spec.ts index 703e3ce65..c0320b015 100644 --- a/services/vault/e2e/fixtures-smoke.spec.ts +++ b/services/vault/e2e/fixtures-smoke.spec.ts @@ -4,8 +4,8 @@ * Verifies: * - the typed seeded-wallet factories return values that satisfy the * mempool wire shape the dApp's `useUTXOs` consumes; - * - `installWallets` writes a sentinel to `window.__BABYLON_E2E_WALLETS__` - * before navigation completes; + * - `installWalletSentinel` writes a sentinel to + * `window.__BABYLON_E2E_WALLETS__` before navigation completes; * - the page-object scaffold reaches the running app (AppShell on `/`). * * The deeper page interactions (clicking through the deposit modal, @@ -28,8 +28,9 @@ test("seededBtcWallet exposes mempool-wire payloads that sum to the seeded amoun const totalValue = wallet.mempoolUtxos.reduce((s, u) => s + u.value, 0); expect(BigInt(totalValue)).toBe(250_000n); expect(wallet.mempoolAddressInfo.isvalid).toBe(true); - // P2TR scriptPubKey: 0x5120 (OP_1 push-32) + 32-byte x-only pubkey (64 hex) - expect(wallet.mempoolAddressInfo.scriptPubKey).toMatch(/^5120[0-9a-f]{64}$/); + // Default address is signet P2WPKH (`tb1q...`), so the scriptPubKey + // is 0014 (OP_0 push-20) + 20-byte hash placeholder (40 hex). + expect(wallet.mempoolAddressInfo.scriptPubKey).toMatch(/^0014[0-9a-f]{40}$/); }); test("seededBtcWallet rejects utxoSplit that doesn't sum to amount", ({ @@ -47,15 +48,15 @@ test("seededEthWallet exposes balanceWeiHex as a valid quantity", ({ expect(wallet.balanceWeiHex).toBe(`0x${(5n * 10n ** 18n).toString(16)}`); }); -test("installWallets sets the e2e wallet global before navigation", async ({ +test("installWalletSentinel sets the e2e wallet global before navigation", async ({ page, seededBtcWallet, - installWallets, + installWalletSentinel, }) => { const wallet = seededBtcWallet({ amount: 100_000n }); await mockMempoolForSeededBtcWallet(page, wallet); await mockVpProxy(page); - await installWallets({ btc: wallet }); + await installWalletSentinel({ btc: wallet }); await page.goto("/"); const installed = await page.evaluate((name) => { diff --git a/services/vault/e2e/fixtures/index.ts b/services/vault/e2e/fixtures/index.ts index 12cc5bf41..143b1585f 100644 --- a/services/vault/e2e/fixtures/index.ts +++ b/services/vault/e2e/fixtures/index.ts @@ -12,6 +12,7 @@ export type { } from "./mockEthWallet"; export { mockEthRpc, + mockEthRpcForSeededWallet, mockGraphql, mockMempoolForSeededBtcWallet, mockVpProxy, diff --git a/services/vault/e2e/fixtures/networkRoutes.ts b/services/vault/e2e/fixtures/networkRoutes.ts index 54824d0eb..7e7d248da 100644 --- a/services/vault/e2e/fixtures/networkRoutes.ts +++ b/services/vault/e2e/fixtures/networkRoutes.ts @@ -16,6 +16,7 @@ import type { VpHealthSnapshot } from "../../src/types/vpHealth"; import type { SeededBtcWallet, + SeededEthWallet, SeededMempoolAddressInfo, SeededMempoolUtxo, } from "./seededWallets"; @@ -105,12 +106,16 @@ export async function mockVpProxy( }); } +const SEPOLIA_CHAIN_ID_HEX = "0xaa36a7"; + /** * Mock the ETH JSON-RPC endpoint. The dApp issues `eth_chainId`, * `eth_call`, `eth_getBalance`, etc. to `NEXT_PUBLIC_ETH_RPC_URL`. The * default handler answers `eth_chainId` with sepolia and returns null * for everything else - tests that need contract reads must supply a - * handler keyed on `(method, params)`. + * handler keyed on `(method, params)`. Use + * `mockEthRpcForSeededWallet` when the seeded ETH balance must be + * reflected in `eth_getBalance` responses. */ export async function mockEthRpc( page: Page, @@ -132,7 +137,7 @@ export async function mockEthRpc( if (handler) { result = handler(method, params); } else if (method === "eth_chainId") { - result = "0xaa36a7"; + result = SEPOLIA_CHAIN_ID_HEX; } await jsonResponse(route, { jsonrpc: "2.0", @@ -142,6 +147,38 @@ export async function mockEthRpc( }); } +/** + * ETH JSON-RPC routes for a single seeded ETH wallet. Answers + * `eth_chainId`, `eth_blockNumber`, and `eth_getBalance` (for the + * wallet's account address) from the seeded fixture so simple + * connect/balance flows resolve without per-test handler wiring. Any + * other method falls through to `handler` if supplied, otherwise + * returns null - tests that exercise contract reads must pass a + * handler keyed on `(method, params)`. + */ +export async function mockEthRpcForSeededWallet( + page: Page, + wallet: Pick, + handler?: (method: string, params: unknown[]) => unknown, +): Promise { + const accountAddress = wallet.account.address.toLowerCase(); + await mockEthRpc(page, (method, params) => { + if (method === "eth_chainId") return SEPOLIA_CHAIN_ID_HEX; + if (method === "eth_blockNumber") return "0x1"; + if (method === "eth_getBalance") { + const [target] = params as [string | undefined]; + if ( + typeof target === "string" && + target.toLowerCase() === accountAddress + ) { + return wallet.balanceWeiHex; + } + return "0x0"; + } + return handler ? handler(method, params) : null; + }); +} + /** * Mock the GraphQL endpoint with a request-shape-aware handler. The * vault app uses `graphql-request` so each request is `POST` with a diff --git a/services/vault/e2e/fixtures/seededWallets.ts b/services/vault/e2e/fixtures/seededWallets.ts index 9930bb274..6622522e0 100644 --- a/services/vault/e2e/fixtures/seededWallets.ts +++ b/services/vault/e2e/fixtures/seededWallets.ts @@ -80,9 +80,7 @@ function deriveTxid(index: number): string { return `ee${"00".repeat(30)}${index.toString(16).padStart(4, "0")}`; } -function deriveScriptPubKey(address: string): string { - // Placeholder P2TR scriptPubKey: `5120` (OP_1 + push-32) plus a - // synthetic 32-byte x-only pubkey derived from the address. +function hashAddressToHex(address: string, hexChars: number): string { // assertValidScriptPubKey requires hex bytes, so we hash the address // into hex rather than reusing the bech32 characters directly. The // value is not a real signing key - tests that exercise PSBT @@ -91,8 +89,26 @@ function deriveScriptPubKey(address: string): string { for (const ch of address) { hash = (hash * 1315423911n) ^ BigInt(ch.charCodeAt(0)); } - const hex = hash.toString(16).padStart(64, "0").slice(-64); - return `5120${hex}`; + return hash.toString(16).padStart(hexChars, "0").slice(-hexChars); +} + +function deriveScriptPubKey(address: string): string { + // Emit a placeholder scriptPubKey that matches the address's witness + // type so consumers that branch on script shape (P2WPKH 20-byte hash + // vs P2TR 32-byte x-only pubkey) see a self-consistent fixture. + const lower = address.toLowerCase(); + if (lower.startsWith("bc1q") || lower.startsWith("tb1q")) { + // P2WPKH: 0014 (OP_0 + push-20) + 20-byte hash160 placeholder. + return `0014${hashAddressToHex(address, 40)}`; + } + if (lower.startsWith("bc1p") || lower.startsWith("tb1p")) { + // P2TR: 5120 (OP_1 + push-32) + 32-byte x-only pubkey placeholder. + return `5120${hashAddressToHex(address, 64)}`; + } + // Unrecognized witness type - fall back to P2TR placeholder so the + // value still passes assertValidScriptPubKey. Tests that need an + // exact match must override via the wallet `script` API. + return `5120${hashAddressToHex(address, 64)}`; } function buildUtxos( @@ -106,12 +122,22 @@ function buildUtxos( `seededBtcWallet: utxoSplit values sum to ${total}n, expected ${amount}n`, ); } - return values.map((value, index) => ({ - txid: deriveTxid(index), - vout: 0, - value: Number(value), - status: { confirmed: true }, - })); + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + return values.map((value, index) => { + if (value > maxSafe) { + throw new Error( + `seededBtcWallet: utxo value ${value}n exceeds Number.MAX_SAFE_INTEGER ` + + `(${Number.MAX_SAFE_INTEGER}); mempool wire payload uses number, ` + + `splitting into smaller UTXOs is required`, + ); + } + return { + txid: deriveTxid(index), + vout: 0, + value: Number(value), + status: { confirmed: true }, + }; + }); } export function seededBtcWallet( diff --git a/services/vault/e2e/fixtures/test.ts b/services/vault/e2e/fixtures/test.ts index 960a7c292..9c00e031f 100644 --- a/services/vault/e2e/fixtures/test.ts +++ b/services/vault/e2e/fixtures/test.ts @@ -43,10 +43,17 @@ export interface VaultE2EFixtures { /** Build a seeded ETH wallet with a declared balance. */ seededEthWallet: (options: SeededEthWalletOptions) => SeededEthWallet; /** - * Install the given wallets on `window.__BABYLON_E2E_WALLETS__` - * before the next navigation. Omit fields to skip. + * Install a JSON-only sentinel describing the seeded wallets on + * `window.__BABYLON_E2E_WALLETS__` before the next navigation. This + * does NOT install usable provider objects: closures (provider + * methods, script queues) do not survive structured-clone across the + * Node/browser boundary, so the page-side payload is the sentinel + * shape `{ kind, address }`, not the full `MockBtcWallet`/ + * `MockEthWallet`. Full page-side provider construction is the + * responsibility of #1592 (single-vault deposit happy-path); here + * the sentinel proves the bridge fires before navigation. */ - installWallets: (wallets: { + installWalletSentinel: (wallets: { btc?: SeededBtcWallet | null; eth?: SeededEthWallet | null; }) => Promise; @@ -66,7 +73,7 @@ interface WalletSentinelPayload { eth?: WalletSentinel; } -async function installWalletsOnPage( +async function installWalletSentinelOnPage( page: Page, globalName: string, payload: WalletSentinelPayload, @@ -105,9 +112,9 @@ export const test = base.extend({ seededEthWallet: async ({}, use) => { await use(seededEthWallet); }, - installWallets: async ({ page }, use) => { + installWalletSentinel: async ({ page }, use) => { await use(async (wallets) => { - await installWalletsOnPage(page, E2E_WALLETS_GLOBAL, { + await installWalletSentinelOnPage(page, E2E_WALLETS_GLOBAL, { btc: toSentinel(wallets.btc, "seeded-btc"), eth: toSentinel(wallets.eth, "seeded-eth"), }); diff --git a/services/vault/e2e/pages/Dashboard.ts b/services/vault/e2e/pages/Dashboard.ts index a9cce55cb..a6464ef8c 100644 --- a/services/vault/e2e/pages/Dashboard.ts +++ b/services/vault/e2e/pages/Dashboard.ts @@ -18,13 +18,19 @@ export class Dashboard { } get withdrawButton(): Locator { - // Per-row withdraw entry inside the collateral list. Tests with - // multiple positions should narrow via `vaultRow(...)` first. + // Per-card withdraw entry inside the collateral list. Tests with + // multiple positions should narrow via `vaultCard(...)` first. return this.page.getByRole("button", { name: /^Withdraw/i }); } - /** Locator for a vault row matched by visible text (vault address / id). */ - vaultRow(matcher: string | RegExp): Locator { - return this.page.getByRole("row", { name: matcher }); + /** + * Locator for a vault card matched by visible text (truncated pegin + * tx hash, provider name, BTC amount, etc.). The collateral list + * renders each position as a `
` card (see + * `CollateralVaultItem`), not an ARIA row, so we filter by text + * rather than `getByRole("row")`. + */ + vaultCard(matcher: string | RegExp): Locator { + return this.page.locator("div").filter({ hasText: matcher }).first(); } } diff --git a/services/vault/scripts/e2e-env.mjs b/services/vault/scripts/e2e-env.mjs index 01423c0cf..0b144b008 100644 --- a/services/vault/scripts/e2e-env.mjs +++ b/services/vault/scripts/e2e-env.mjs @@ -31,9 +31,12 @@ const PORTS = { const DEFAULT_BTC_ADDRESS = "tb1qce0n0rv27dwx37dfvhxaaly4lnwelqjuqywvka"; const DEFAULT_BALANCE_SATS = 100_000_000; -const DEFAULT_SCRIPT_PUBKEY = `5120${DEFAULT_BTC_ADDRESS - .slice(-40) - .padStart(40, "0")}`; +// Placeholder P2WPKH scriptPubKey matching DEFAULT_BTC_ADDRESS's witness +// version 0 (`tb1q...`): 0014 (OP_0 + push-20) + 20-byte hash placeholder. +// Real bech32 characters are not hex, so this hex placeholder is a +// stand-in that satisfies assertValidScriptPubKey in the dApp; tests +// that need a real address-derived script must wire one explicitly. +const DEFAULT_SCRIPT_PUBKEY = `0014${"ab".repeat(20)}`; function jsonResponse(res, body, status = 200) { res.writeHead(status, { From a409d2db4d18f06407446a3144d0d029faf321c4 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Mon, 18 May 2026 00:55:06 +0100 Subject: [PATCH 3/3] feat(vault): e2e wallet-connect + BTC injectable wallet (#1590) --- services/vault/e2e/fixtures/index.ts | 6 + services/vault/e2e/fixtures/networkRoutes.ts | 33 +++- services/vault/e2e/fixtures/test.ts | 25 ++- .../vault/e2e/fixtures/walletPageInjection.ts | 121 ++++++++++++ services/vault/e2e/wallet-connect.spec.ts | 172 ++++++++++++++++++ 5 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 services/vault/e2e/fixtures/walletPageInjection.ts create mode 100644 services/vault/e2e/wallet-connect.spec.ts diff --git a/services/vault/e2e/fixtures/index.ts b/services/vault/e2e/fixtures/index.ts index 143b1585f..f2cdd764a 100644 --- a/services/vault/e2e/fixtures/index.ts +++ b/services/vault/e2e/fixtures/index.ts @@ -14,6 +14,7 @@ export { mockEthRpc, mockEthRpcForSeededWallet, mockGraphql, + mockHealthCheck, mockMempoolForSeededBtcWallet, mockVpProxy, } from "./networkRoutes"; @@ -35,3 +36,8 @@ export { injectWallets, } from "./walletInjection"; export type { InjectedWallets } from "./walletInjection"; +export { + btcWalletConfigFromSeeded, + injectBtcWalletProvider, +} from "./walletPageInjection"; +export type { BtcWalletPageConfig } from "./walletPageInjection"; diff --git a/services/vault/e2e/fixtures/networkRoutes.ts b/services/vault/e2e/fixtures/networkRoutes.ts index 7e7d248da..72a5d0874 100644 --- a/services/vault/e2e/fixtures/networkRoutes.ts +++ b/services/vault/e2e/fixtures/networkRoutes.ts @@ -90,7 +90,11 @@ export async function mockVpProxy( ): Promise { const snapshots = options.healthSnapshots ?? []; await page.route("**/vp-health", (route) => jsonResponse(route, snapshots)); - await page.route("**/rpc/*", async (route) => { + // Scope the per-VP rpc route to the proxy port so we don't shadow + // the eth-rpc endpoint (also at /rpc). playwright.config.ts pins + // VP_PROXY_URL to localhost:9998; if you re-point that env var, + // update this pattern. + await page.route("http://localhost:9998/rpc/*", async (route) => { if (!options.rpcHandler) { return jsonResponse( route, @@ -179,6 +183,33 @@ export async function mockEthRpcForSeededWallet( }); } +/** + * Mock the `/health` endpoint that `checkGeofencing` calls. The check + * lives at `${graphqlOrigin}/health` and is gated on HTTP status: + * - 200 -> not blocked + * - 451 -> geo-blocked, surfaces "Not available in your region" + * - other -> treated as healthy (only 451 hard-blocks) + * + * The default 200 unblocks the connect-button path. Tests that need + * geo-blocking pass `status: 451`. + */ +export async function mockHealthCheck( + page: Page, + options: { status?: number; delayMs?: number } = {}, +): Promise { + const status = options.status ?? 200; + await page.route("**/health", async (route) => { + if (options.delayMs) { + await new Promise((resolve) => setTimeout(resolve, options.delayMs)); + } + await route.fulfill({ + status, + contentType: "application/json", + body: status === 200 ? "{}" : `{"error":"status ${status}"}`, + }); + }); +} + /** * Mock the GraphQL endpoint with a request-shape-aware handler. The * vault app uses `graphql-request` so each request is `POST` with a diff --git a/services/vault/e2e/fixtures/test.ts b/services/vault/e2e/fixtures/test.ts index 9c00e031f..5028d1f5e 100644 --- a/services/vault/e2e/fixtures/test.ts +++ b/services/vault/e2e/fixtures/test.ts @@ -36,6 +36,10 @@ import { type SeededEthWalletOptions, } from "./seededWallets"; import { E2E_WALLETS_GLOBAL } from "./walletInjection"; +import { + btcWalletConfigFromSeeded, + injectBtcWalletProvider, +} from "./walletPageInjection"; export interface VaultE2EFixtures { /** Build a seeded BTC wallet with a declared balance. */ @@ -78,12 +82,11 @@ async function installWalletSentinelOnPage( globalName: string, payload: WalletSentinelPayload, ): Promise { - // The full mocks contain closures that don't survive structured-clone - // across the Node/browser boundary, so we install a JSON-only - // sentinel here. Reconstructing the live mock provider page-side is - // the single-vault deposit happy-path ticket's responsibility - // (#1592). For #1589 the install proves the bridge fires before - // navigation - enough to unblock per-flow tickets. + // The sentinel records that a wallet was requested by the test, for + // diagnostic / introspection use. Actual `window.btcwallet` wiring + // happens via `installBtcOnPage` below - the wallet-connector reads + // from that exact global. ETH provider injection still waits on + // mocking Reown's AppKit (deferred to a follow-up). await page.addInitScript( ({ globalName: name, sentinel }) => { (window as unknown as Record)[name] = sentinel; @@ -92,6 +95,13 @@ async function installWalletSentinelOnPage( ); } +async function installBtcOnPage( + page: Page, + wallet: SeededBtcWallet, +): Promise { + await injectBtcWalletProvider(page, btcWalletConfigFromSeeded(wallet)); +} + function toSentinel( wallet: SeededBtcWallet | SeededEthWallet | null | undefined, kind: "seeded-btc" | "seeded-eth", @@ -118,6 +128,9 @@ export const test = base.extend({ btc: toSentinel(wallets.btc, "seeded-btc"), eth: toSentinel(wallets.eth, "seeded-eth"), }); + if (wallets.btc) { + await installBtcOnPage(page, wallets.btc); + } }); }, appShell: async ({ page }, use) => { diff --git a/services/vault/e2e/fixtures/walletPageInjection.ts b/services/vault/e2e/fixtures/walletPageInjection.ts new file mode 100644 index 000000000..b59697884 --- /dev/null +++ b/services/vault/e2e/fixtures/walletPageInjection.ts @@ -0,0 +1,121 @@ +/** + * Page-side wallet injection. + * + * The babylon-wallet-connector's injectable BTC adapter reads from + * `window.btcwallet` and treats whatever it finds there as an + * `IBTCProvider`. To drive the connect flow in e2e we install a + * deterministic provider on that global *before* the dApp loads, via + * `page.addInitScript`. + * + * The mock provider lives entirely in page context: `addInitScript` + * serialises its callback to a string and re-evaluates it in the + * browser, so any function the callback references must be defined + * inside the callback body. Closures captured Node-side do not + * survive the boundary. Anything the test needs to vary across runs + * therefore travels as a plain-JSON `config` arg. + * + * ETH provider injection is a separate problem: the dApp uses Reown's + * AppKit, which has its own React state machine. Mocking that lands + * with a follow-up ticket (the per-flow deposit specs that need a + * fully-connected wallet pair will block on it). + */ + +import type { Page } from "@playwright/test"; + +import type { SeededBtcWallet } from "./seededWallets"; + +export interface BtcWalletPageConfig { + /** Bech32 address `getAddress` returns. */ + address: string; + /** 33-byte compressed pubkey hex `getPublicKeyHex` returns. */ + publicKeyHex: string; + /** Network string ("mainnet" | "signet" | "testnet"). */ + network: string; + /** Display name for the wallet menu. */ + providerName: string; + /** Data-URI icon. */ + providerIcon: string; + /** Marker so the page-side instance is recognisable from devtools. */ + e2e: true; +} + +/** + * Install a deterministic BTC provider on `window.btcwallet`. Must be + * called BEFORE the first `page.goto` so the injectable adapter + * discovers it during module evaluation. + */ +export async function injectBtcWalletProvider( + page: Page, + config: BtcWalletPageConfig, +): Promise { + await page.addInitScript((cfg) => { + const SIGNED_MESSAGE_HEX = "cd".repeat(64); + + // sha256 of `${appName}:${context}` rendered as lowercase hex. The + // deriveContextHash output must be deterministic across runs but + // not collide with real key material - the mock prefixes the + // appName with a marker via the chosen input. + async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const buffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + const listeners = new Map void>>(); + + const provider = { + connectWallet: async () => undefined, + getAddress: async () => cfg.address, + getPublicKeyHex: async () => cfg.publicKeyHex, + getNetwork: async () => cfg.network, + getInscriptions: async () => [], + getWalletProviderName: async () => cfg.providerName, + getWalletProviderIcon: async () => cfg.providerIcon, + // Echo the PSBT back so callers that just decode-and-re-encode keep + // working. Tests that need a real partial signature must reach + // into the provider via `window.btcwallet` and overwrite the + // method per-test. + signPsbt: async (psbtHex: string) => psbtHex, + signPsbts: async (psbtsHexes: string[]) => [...psbtsHexes], + signMessage: async () => SIGNED_MESSAGE_HEX, + deriveContextHash: async (appName: string, context: string) => + sha256Hex(`${appName}:${context}`), + on: (event: string, cb: (...args: unknown[]) => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(cb); + }, + off: (event: string, cb: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(cb); + }, + // Tests dispatch events by reading `window.btcwallet.__emit`. + __emit: (event: string, ...args: unknown[]) => { + listeners.get(event)?.forEach((cb) => cb(...args)); + }, + __e2eConfig: cfg, + }; + + (window as unknown as { btcwallet?: unknown }).btcwallet = provider; + }, config); +} + +/** + * Convenience adapter for the seeded wallets in `seededWallets.ts`. + * Builds the page-side config from a SeededBtcWallet so tests can + * pass the same value to mempool route helpers and the injector. + */ +export function btcWalletConfigFromSeeded( + wallet: SeededBtcWallet, + overrides: Partial = {}, +): BtcWalletPageConfig { + return { + address: wallet.address, + publicKeyHex: `02${"ab".repeat(32)}`, + network: "signet", + providerName: "E2E Mock BTC", + providerIcon: "data:image/svg+xml;base64,PHN2Zy8+", + e2e: true, + ...overrides, + }; +} diff --git a/services/vault/e2e/wallet-connect.spec.ts b/services/vault/e2e/wallet-connect.spec.ts new file mode 100644 index 000000000..4ea4bc1d8 --- /dev/null +++ b/services/vault/e2e/wallet-connect.spec.ts @@ -0,0 +1,172 @@ +/** + * Wallet connect / disconnect (#1590) - pragmatic scope. + * + * Covers the parts of the ticket that work today with mock-first + * infra: + * - connect-button visible & enabled in healthy state + * - connect-button disabled with "Not available in your region" + * hint when /health returns 451 + * - window.btcwallet injection plants a deterministic IBTCProvider + * before the dApp loads + * - the wallet provider's getAddress / getNetwork methods return + * the values the test declared + * + * Deferred (separate ticket): connected-state UI, ETH connect/disconnect, + * address truncation in the rendered menu, network mismatch banner. + * Connected-state assertions require an ETH provider, which the dApp + * gets through Reown's AppKit - mocking that needs its own design. + */ + +import { + expect, + injectBtcWalletProvider, + mockGraphql, + mockHealthCheck, + mockMempoolForSeededBtcWallet, + mockVpProxy, + test, +} from "./fixtures"; + +const CONNECT_BUTTON_TESTID = "connect-wallet-button"; + +test("connect button is visible and enabled when /health returns 200", async ({ + page, +}) => { + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockVpProxy(page); + + await page.goto("/"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {}); + + const button = page.getByTestId(CONNECT_BUTTON_TESTID); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); +}); + +test("connect button is disabled with geo hint when /health returns 451", async ({ + page, +}) => { + await mockHealthCheck(page, { status: 451 }); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockVpProxy(page); + + await page.goto("/"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {}); + + const button = page.getByTestId(CONNECT_BUTTON_TESTID); + await expect(button).toBeVisible(); + await expect(button).toBeDisabled(); + // react-tooltip stores the hint copy in a data-tooltip-content + // attribute on the trigger span (see core-ui Hint.tsx). Asserting + // on the attribute keeps the test stable regardless of whether the + // tooltip is currently open in the DOM. + await expect( + page.locator("[data-tooltip-content='Not available in your region']"), + ).toBeAttached(); +}); + +test("window.btcwallet provider exposes the declared address and network", async ({ + page, + seededBtcWallet, +}) => { + const wallet = seededBtcWallet({ amount: 100_000n }); + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockVpProxy(page); + await mockMempoolForSeededBtcWallet(page, wallet); + await injectBtcWalletProvider(page, { + address: wallet.address, + publicKeyHex: `02${"ab".repeat(32)}`, + network: "signet", + providerName: "E2E Mock BTC", + providerIcon: "data:image/svg+xml;base64,PHN2Zy8+", + e2e: true, + }); + + await page.goto("/"); + + const probed = await page.evaluate(async () => { + const w = (window as unknown as { + btcwallet?: { + getAddress: () => Promise; + getNetwork: () => Promise; + getWalletProviderName: () => Promise; + __e2eConfig?: { e2e: boolean }; + }; + }).btcwallet; + if (!w) return null; + return { + address: await w.getAddress(), + network: await w.getNetwork(), + name: await w.getWalletProviderName(), + isE2E: w.__e2eConfig?.e2e === true, + }; + }); + + expect(probed).not.toBeNull(); + expect(probed!.address).toBe(wallet.address); + expect(probed!.network).toBe("signet"); + expect(probed!.name).toBe("E2E Mock BTC"); + expect(probed!.isE2E).toBe(true); +}); + +test("window.btcwallet deriveContextHash is deterministic across calls", async ({ + page, +}) => { + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockVpProxy(page); + await injectBtcWalletProvider(page, { + address: "tb1qce0n0rv27dwx37dfvhxaaly4lnwelqjuqywvka", + publicKeyHex: `02${"ab".repeat(32)}`, + network: "signet", + providerName: "E2E Mock BTC", + providerIcon: "data:image/svg+xml;base64,PHN2Zy8+", + e2e: true, + }); + + await page.goto("/"); + + const hashes = await page.evaluate(async () => { + const w = (window as unknown as { + btcwallet?: { + deriveContextHash: (a: string, c: string) => Promise; + }; + }).btcwallet; + if (!w) return null; + const a = await w.deriveContextHash("babylon-vault", "ctx-1"); + const b = await w.deriveContextHash("babylon-vault", "ctx-1"); + const c = await w.deriveContextHash("babylon-vault", "ctx-2"); + return { a, b, c }; + }); + + expect(hashes).not.toBeNull(); + expect(hashes!.a).toBe(hashes!.b); + expect(hashes!.a).not.toBe(hashes!.c); + expect(hashes!.a).toMatch(/^[0-9a-f]{64}$/); +}); + +test("installWalletSentinel fixture wires window.btcwallet from a seeded wallet", async ({ + page, + seededBtcWallet, + installWalletSentinel, +}) => { + const wallet = seededBtcWallet({ amount: 100_000n }); + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockVpProxy(page); + await mockMempoolForSeededBtcWallet(page, wallet); + await installWalletSentinel({ btc: wallet }); + + await page.goto("/"); + + const addressOnPage = await page.evaluate(async () => { + const w = (window as unknown as { + btcwallet?: { getAddress: () => Promise }; + }).btcwallet; + return w ? w.getAddress() : null; + }); + + expect(addressOnPage).toBe(wallet.address); +});