diff --git a/AGENTS.md b/AGENTS.md index 2ab2c0f..71cf3fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,24 +46,40 @@ Notes: ## Running Tests +**Important:** The `BACKEND` env var controls which infrastructure the tests use for deposits/mining: + +- `BACKEND=local` (default) โ€” Uses local docker stack (Bitcoin RPC on localhost:18443, Electrum on localhost:60001). Requires `bitkit-docker` running locally. +- `BACKEND=regtest` โ€” Uses Blocktank API over the internet (remote regtest infrastructure). + +**The `BACKEND` must match how the app was built:** + +- Apps built with `BACKEND=local` connect to localhost electrum โ†’ run tests with `BACKEND=local` +- Apps built with `BACKEND=regtest` connect to remote electrum โ†’ run tests with `BACKEND=regtest` + ```bash -# Android +# Android (local backend - default) npm run e2e:android +# Android (regtest backend - for apps built with BACKEND=regtest) +BACKEND=regtest npm run e2e:android + # iOS npm run e2e:ios +BACKEND=regtest npm run e2e:ios ``` Run a single spec: ```bash npm run e2e:android -- --spec ./test/specs/onboarding.e2e.ts +BACKEND=regtest npm run e2e:android -- --spec ./test/specs/migration.e2e.ts ``` Run by tag: ```bash npm run e2e:android -- --mochaOpts.grep "@backup" +BACKEND=regtest npm run e2e:android -- --mochaOpts.grep "@migration" ``` ## CI Helper Scripts @@ -71,8 +87,13 @@ npm run e2e:android -- --mochaOpts.grep "@backup" These wrap the `npm run e2e:*` commands and capture logs/artifacts: ```bash +# Local backend (default) ./ci_run_android.sh ./ci_run_ios.sh + +# Regtest backend +BACKEND=regtest ./ci_run_android.sh +BACKEND=regtest ./ci_run_ios.sh ``` ## Practical Tips diff --git a/README.md b/README.md index 19acfcf..157ed02 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ If you have `bitkit-e2e-tests`, `bitkit-android`, and `bitkit-ios` checked out i # Legacy RN Android (builds ../bitkit and copies APK to ./aut/bitkit_rn_regtest.apk) ./scripts/build-rn-android-apk.sh +# Legacy RN iOS simulator (builds ../bitkit and copies app to ./aut/bitkit_rn_regtest_ios.app) +./scripts/build-rn-ios-sim.sh + # iOS (builds ../bitkit-ios and copies IPA to ./aut/bitkit_e2e.ipa) ./scripts/build-ios-sim.sh ``` @@ -73,6 +76,9 @@ BACKEND=regtest ./scripts/build-android-apk.sh # Legacy RN Android BACKEND=regtest ./scripts/build-rn-android-apk.sh +# Legacy RN iOS simulator +BACKEND=regtest ./scripts/build-rn-ios-sim.sh + # iOS BACKEND=local ./scripts/build-ios-sim.sh BACKEND=regtest ./scripts/build-ios-sim.sh @@ -82,18 +88,34 @@ BACKEND=regtest ./scripts/build-ios-sim.sh ### ๐Ÿงช Running tests +**Important:** The `BACKEND` environment variable controls which infrastructure the tests use for blockchain operations (deposits, mining blocks): + +| Backend | Infrastructure | When to use | +| ------------------------- | -------------------------------------------------------------------------------- | --------------------------------- | +| `BACKEND=local` (default) | Local docker stack (Bitcoin RPC on localhost:18443, Electrum on localhost:60001) | Apps built with `BACKEND=local` | +| `BACKEND=regtest` | Blocktank API over the internet (remote regtest) | Apps built with `BACKEND=regtest` | + +> โš ๏ธ **The `BACKEND` must match how the app was built.** If the app connects to remote electrum, use `BACKEND=regtest`. If it connects to localhost, use `BACKEND=local`. + ```bash -# Run all tests on Android +# Run all tests on Android (local backend - default) npm run e2e:android +# Run all tests on Android (regtest backend) +BACKEND=regtest npm run e2e:android + # Run all tests on iOS npm run e2e:ios +BACKEND=regtest npm run e2e:ios ``` To run a **specific test file**: ```bash npm run e2e:android -- --spec ./test/specs/onboarding.e2e.ts + +# With regtest backend +BACKEND=regtest npm run e2e:android -- --spec ./test/specs/migration.e2e.ts ``` To run a **specific test case**: @@ -142,19 +164,25 @@ These helper scripts wrap the regular `npm run e2e:*` commands and add CI-friend The Android script will: - Clear and capture `adb logcat` output into `./artifacts/logcat.txt`. -- Reverse the regtest port (`60001`). +- Reverse the regtest port (`60001`) for local backend. - Run the Android E2E tests. - Forward any arguments directly to Mocha/WebdriverIO. **Usage examples:** ```bash -# Run all Android tests (with logcat capture) +# Run all Android tests with local backend (default) ./ci_run_android.sh +# Run all Android tests with regtest backend (for apps built with BACKEND=regtest) +BACKEND=regtest ./ci_run_android.sh + # Run only @backup tests ./ci_run_android.sh --mochaOpts.grep "@backup" +# Run migration tests (typically need regtest backend for RN app) +BACKEND=regtest ./ci_run_android.sh --mochaOpts.grep "@migration" + # Run backup OR onboarding OR onchain tests ./ci_run_android.sh --mochaOpts.grep "@backup|@onboarding|@onchain" @@ -177,9 +205,12 @@ The iOS helper mirrors the Android workflow but tailors it for the Apple Simulat **Usage examples:** ```bash -# Run all iOS tests (with simulator log capture) +# Run all iOS tests with local backend (default) ./ci_run_ios.sh +# Run all iOS tests with regtest backend +BACKEND=regtest ./ci_run_ios.sh + # Run only @onboarding-tagged tests ./ci_run_ios.sh --mochaOpts.grep "@onboarding" diff --git a/scripts/build-rn-android-apk.sh b/scripts/build-rn-android-apk.sh index 6c72ac2..9b15c2e 100755 --- a/scripts/build-rn-android-apk.sh +++ b/scripts/build-rn-android-apk.sh @@ -27,7 +27,7 @@ fi if [[ -z "${ENV_FILE:-}" ]]; then if [[ "$BACKEND" == "regtest" ]]; then - ENV_FILE=".env.test.template" + ENV_FILE=".env.development.template" else ENV_FILE=".env.development" fi diff --git a/scripts/build-rn-ios-sim.sh b/scripts/build-rn-ios-sim.sh new file mode 100755 index 0000000..ee3960b --- /dev/null +++ b/scripts/build-rn-ios-sim.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build the legacy Bitkit RN iOS simulator app from ../bitkit and copy into aut/ +# +# Inputs/roots: +# - E2E root: this repo (bitkit-e2e-tests) +# - RN root: ../bitkit (resolved relative to this script) +# +# Output: +# - Copies .app -> aut/bitkit_rn_regtest_ios.app +# +# Usage: +# ./scripts/build-rn-ios-sim.sh [debug|release] +# BACKEND=regtest ./scripts/build-rn-ios-sim.sh +# ENV_FILE=.env.test.template ./scripts/build-rn-ios-sim.sh +set -euo pipefail + +E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RN_ROOT="$(cd "$E2E_ROOT/../bitkit" && pwd)" + +BUILD_TYPE="${1:-debug}" +BACKEND="${BACKEND:-regtest}" + +if [[ "$BUILD_TYPE" != "debug" && "$BUILD_TYPE" != "release" ]]; then + echo "ERROR: Unsupported build type: $BUILD_TYPE (expected debug|release)" >&2 + exit 1 +fi + +if [[ -z "${ENV_FILE:-}" ]]; then + if [[ "$BACKEND" == "regtest" ]]; then + ENV_FILE=".env.development.template" + else + ENV_FILE=".env.development" + fi +fi + +if [[ ! -f "$RN_ROOT/$ENV_FILE" ]]; then + echo "ERROR: Env file not found: $RN_ROOT/$ENV_FILE" >&2 + exit 1 +fi + +echo "Building RN iOS simulator app (BACKEND=$BACKEND, ENV_FILE=$ENV_FILE, BUILD_TYPE=$BUILD_TYPE)..." + +pushd "$RN_ROOT" >/dev/null +if [[ -f .env ]]; then + cp .env .env.bak +fi +cp "$ENV_FILE" .env +E2E_TESTS=true yarn "e2e:build:ios-$BUILD_TYPE" +if [[ -f .env.bak ]]; then + mv .env.bak .env +else + rm -f .env +fi +popd >/dev/null + +if [[ "$BUILD_TYPE" == "debug" ]]; then + IOS_CONFIG="Debug" +else + IOS_CONFIG="Release" +fi + +APP_PATH="$RN_ROOT/ios/build/Build/Products/${IOS_CONFIG}-iphonesimulator/bitkit.app" +if [[ ! -d "$APP_PATH" ]]; then + echo "ERROR: iOS .app not found at: $APP_PATH" >&2 + exit 1 +fi + +OUT="$E2E_ROOT/aut" +mkdir -p "$OUT" +OUT_APP="$OUT/bitkit_rn_${BACKEND}_ios.app" +rm -rf "$OUT_APP" +cp -R "$APP_PATH" "$OUT_APP" +echo "RN iOS simulator app copied to: $OUT_APP" diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f1eb58b..6170641 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -1,6 +1,6 @@ import type { ChainablePromiseElement } from 'webdriverio'; import { reinstallApp } from './setup'; -import BitcoinJsonRpc from 'bitcoin-json-rpc'; +import { deposit, mineBlocks } from './regtest'; export const sleep = (ms: number) => browser.pause(ms); @@ -88,11 +88,11 @@ export function elementByText( } else { if (strategy === 'exact') { return $( - `-ios predicate string:(type == "XCUIElementTypeStaticText" OR type == "XCUIElementTypeButton") AND (label == "${text}" OR value == "${text}")` + `-ios predicate string:(type == "XCUIElementTypeStaticText" OR type == "XCUIElementTypeButton" OR type == "XCUIElementTypeOther") AND (label == "${text}" OR value == "${text}")` ); } return $( - `-ios predicate string:(type == "XCUIElementTypeStaticText" OR type == "XCUIElementTypeButton") AND label CONTAINS "${text}"` + `-ios predicate string:(type == "XCUIElementTypeStaticText" OR type == "XCUIElementTypeButton" OR type == "XCUIElementTypeOther") AND label CONTAINS "${text}"` ); } } @@ -312,7 +312,20 @@ export async function typeText(testId: string, text: string) { type Direction = 'left' | 'right' | 'up' | 'down'; -export async function swipeFullScreen(direction: Direction) { +export async function swipeFullScreen( + direction: Direction, + { + downStartYPercent = 0.2, + downEndYPercent = 0.8, + upStartYPercent = 0.8, + upEndYPercent = 0.2, + }: { + downStartYPercent?: number; + downEndYPercent?: number; + upStartYPercent?: number; + upEndYPercent?: number; + } = {} +) { const { width, height } = await driver.getWindowSize(); let startX = width / 2; @@ -330,12 +343,12 @@ export async function swipeFullScreen(direction: Direction) { endX = width * 0.8; break; case 'up': - startY = height * 0.8; - endY = height * 0.2; + startY = height * upStartYPercent; + endY = height * upEndYPercent; break; case 'down': - startY = height * 0.2; - endY = height * 0.8; + startY = height * downStartYPercent; + endY = height * downEndYPercent; break; } @@ -522,14 +535,24 @@ export async function restoreWallet( { passphrase, expectQuickPayTimedSheet = false, - }: { passphrase?: string; expectQuickPayTimedSheet?: boolean } = {} + expectBackupSheet = false, + reinstall = true, + }: { + passphrase?: string; + expectQuickPayTimedSheet?: boolean; + expectBackupSheet?: boolean; + reinstall?: boolean; + } = {} ) { console.info('โ†’ Restoring wallet with seed:', seed); // Let cloud state flush - carried over from Detox await sleep(5000); // Reinstall app to wipe all data - await reinstallApp(); + if (reinstall) { + console.info('Reinstalling app to reset state...'); + await reinstallApp(); + } // Terms of service await elementById('Continue').waitForDisplayed(); @@ -559,15 +582,17 @@ export async function restoreWallet( await tap('RestoreButton'); await waitForSetupWalletScreenFinish(); - await handleAndroidAlert(); - // Wait for Get Started const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed(); + await getStarted.waitForDisplayed({ timeout: 120000 }); await tap('GetStartedButton'); await sleep(1000); await handleAndroidAlert(); + if (expectBackupSheet) { + await dismissBackupTimedSheet(); + } + if (expectQuickPayTimedSheet) { await dismissQuickPayIntro(); } @@ -642,37 +667,50 @@ export async function getAddressFromQRCode(which: addressType): Promise return address; } -export async function mineBlocks(rpc: BitcoinJsonRpc, blocks: number = 1) { - for (let i = 0; i < blocks; i++) { - await rpc.generateToAddress(1, await rpc.getNewAddress()); +/** + * Funds the wallet on regtest. + * Gets the receive address from the app, deposits sats, and optionally mines blocks. + * Uses local Bitcoin RPC or Blocktank API based on BACKEND env var. + */ +export async function fundOnchainWallet({ + sats, + blocksToMine = 1, +}: { + sats?: number; + blocksToMine?: number; +} = {}) { + const address = await getReceiveAddress(); + await swipeFullScreen('down'); + await deposit(address, sats); + if (blocksToMine > 0) { + await mineBlocks(blocksToMine); } } -export async function receiveOnchainFunds( - rpc: BitcoinJsonRpc, - { - sats = 100_000, - blocksToMine = 1, - expectHighBalanceWarning = false, - }: { - sats?: number; - blocksToMine?: number; - expectHighBalanceWarning?: boolean; - } = {} -) { - // convert sats โ†’ btc string - const btc = (sats / 100_000_000).toString(); +/** + * Receives onchain funds and verifies the balance. + * Uses local Bitcoin RPC or Blocktank API based on BACKEND env var. + */ +export async function receiveOnchainFunds({ + sats = 100_000, + blocksToMine = 1, + expectHighBalanceWarning = false, +}: { + sats?: number; + blocksToMine?: number; + expectHighBalanceWarning?: boolean; +} = {}) { // format sats with spaces every 3 digits const formattedSats = sats.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); // receive some first const address = await getReceiveAddress(); await swipeFullScreen('down'); - await rpc.sendToAddress(address, btc); + await deposit(address, sats); await acknowledgeReceivedPayment(); - await mineBlocks(rpc, blocksToMine); + await mineBlocks(blocksToMine); const moneyText = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); await expect(moneyText).toHaveText(formattedSats); diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index 600d1e4..a79bbb3 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -18,10 +18,15 @@ export function getAppPath(): string { throw new Error(`App path not defined in capabilities (tried ${possibleKeys.join(', ')})`); } -export const bitcoinURL = 'http://polaruser:polarpass@127.0.0.1:43782'; +export const bitcoinURL = + process.env.BITCOIN_RPC_URL ?? 'http://polaruser:polarpass@127.0.0.1:43782'; export const electrumHost = '127.0.0.1'; export const electrumPort = 60001; +// Blocktank API for regtest operations (deposit, mine blocks, pay invoices) +export const blocktankURL = + process.env.BLOCKTANK_URL ?? 'https://api.stag0.blocktank.to/blocktank/api/v2'; + export type LndConfig = { server: string; tls: string; diff --git a/test/helpers/electrum.ts b/test/helpers/electrum.ts index 7b55806..44ce45b 100644 --- a/test/helpers/electrum.ts +++ b/test/helpers/electrum.ts @@ -3,6 +3,7 @@ import tls from 'tls'; import BitcoinJsonRpc from 'bitcoin-json-rpc'; import * as electrum from 'rn-electrum-client/helpers'; import { bitcoinURL, electrumHost, electrumPort } from './constants'; +import { getBackend } from './regtest'; const peer = { host: electrumHost, @@ -17,11 +18,33 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -// Connect to the Bitcoin Core node and Electrum server to wait for Electrum to sync -const initElectrum = async (): Promise<{ +export type ElectrumClient = { waitForSync: () => Promise; stop: () => Promise; -}> => { +}; + +// No-op electrum client for regtest backend (app connects to remote Electrum directly) +const noopElectrum: ElectrumClient = { + waitForSync: async () => { + // For regtest backend, we just wait a bit for the app to sync with remote Electrum + console.info('โ†’ [regtest] Waiting for app to sync with remote Electrum...'); + await sleep(2000); + }, + stop: async () => { + // Nothing to stop for regtest + }, +}; + +// Connect to the Bitcoin Core node and Electrum server to wait for Electrum to sync +const initElectrum = async (): Promise => { + const backend = getBackend(); + + // For regtest backend, return no-op client (app connects to remote Electrum directly) + if (backend !== 'local') { + console.info(`โ†’ [${backend}] Skipping local Electrum init (using remote Electrum)`); + return noopElectrum; + } + let electrumHeight = 0; try { diff --git a/test/helpers/regtest.ts b/test/helpers/regtest.ts new file mode 100644 index 0000000..95ed115 --- /dev/null +++ b/test/helpers/regtest.ts @@ -0,0 +1,254 @@ +/** + * Regtest helpers that abstract the backend (local Bitcoin RPC vs Blocktank API). + * + * Set BACKEND=local to use local docker stack (Bitcoin RPC on localhost). + * Set BACKEND=regtest to use Blocktank API (company regtest over the internet). + * + * Default is 'local' for backwards compatibility with existing tests. + */ + +import BitcoinJsonRpc from 'bitcoin-json-rpc'; +import { bitcoinURL, blocktankURL } from './constants'; + +export type Backend = 'local' | 'regtest'; + +export function getBackend(): Backend { + const backend = process.env.BACKEND || 'local'; // Use || to handle empty string + if (backend !== 'local' && backend !== 'regtest') { + throw new Error(`Invalid BACKEND: ${backend}. Expected 'local' or 'regtest'.`); + } + return backend; +} + +// Local backend (Bitcoin RPC) + +let _rpc: BitcoinJsonRpc | null = null; + +function getRpc(): BitcoinJsonRpc { + if (!_rpc) { + _rpc = new BitcoinJsonRpc(bitcoinURL); + } + return _rpc; +} + +async function localDeposit(address: string, amountSat?: number): Promise { + const rpc = getRpc(); + const btc = amountSat ? (amountSat / 100_000_000).toString() : '0.001'; // default 100k sats + console.info(`โ†’ [local] Sending ${btc} BTC to ${address}`); + const txid = await rpc.sendToAddress(address, btc); + console.info(`โ†’ [local] txid: ${txid}`); + return txid; +} + +async function localMineBlocks(count: number): Promise { + const rpc = getRpc(); + console.info(`โ†’ [local] Mining ${count} block(s)...`); + for (let i = 0; i < count; i++) { + await rpc.generateToAddress(1, await rpc.getNewAddress()); + } + console.info(`โ†’ [local] Mined ${count} block(s)`); +} + +// Blocktank backend (regtest API over HTTPS) + +async function blocktankDeposit(address: string, amountSat?: number): Promise { + const url = `${blocktankURL}/regtest/chain/deposit`; + const body: { address: string; amountSat?: number } = { address }; + if (amountSat !== undefined) { + body.amountSat = amountSat; + } + + console.info(`โ†’ [blocktank] Deposit to ${address}${amountSat ? ` (${amountSat} sats)` : ''}`); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Blocktank deposit failed: ${response.status} - ${errorText}`); + } + + const txid = await response.text(); + console.info(`โ†’ [blocktank] txid: ${txid}`); + return txid; +} + +async function blocktankMineBlocks(count: number): Promise { + const url = `${blocktankURL}/regtest/chain/mine`; + + console.info(`โ†’ [blocktank] Mining ${count} block(s)...`); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Blocktank mine failed: ${response.status} - ${errorText}`); + } + + console.info(`โ†’ [blocktank] Mined ${count} block(s)`); +} + +async function blocktankPayInvoice(invoice: string, amountSat?: number): Promise { + const url = `${blocktankURL}/regtest/channel/pay`; + const body: { invoice: string; amountSat?: number } = { invoice }; + if (amountSat !== undefined) { + body.amountSat = amountSat; + } + + console.info(`โ†’ [blocktank] Paying invoice...`); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Blocktank pay invoice failed: ${response.status} - ${errorText}`); + } + + const paymentId = await response.text(); + console.info(`โ†’ [blocktank] Payment ID: ${paymentId}`); + return paymentId; +} + +// Unified interface + +/** + * Returns the Bitcoin RPC client for direct operations. + * Only works with BACKEND=local. Throws if using regtest backend. + * Useful for test utilities that need direct RPC access (e.g., getting addresses to send TO). + */ +export function getBitcoinRpc(): BitcoinJsonRpc { + const backend = getBackend(); + if (backend !== 'local') { + throw new Error('getBitcoinRpc() only works with BACKEND=local'); + } + return getRpc(); +} + +/** + * Ensures the local bitcoind has enough funds for testing. + * Only runs when BACKEND=local. Skips silently when BACKEND=regtest + * (Blocktank handles funding via its API). + * + * Call this in test `before` hooks instead of directly using RPC. + */ +export async function ensureLocalFunds(minBtc: number = 10): Promise { + const backend = getBackend(); + if (backend !== 'local') { + console.info(`โ†’ [${backend}] Skipping local bitcoind funding (using Blocktank API)`); + return; + } + + const rpc = getRpc(); + let balance = await rpc.getBalance(); + const address = await rpc.getNewAddress(); + + while (balance < minBtc) { + console.info(`โ†’ [local] Mining blocks to fund local bitcoind (balance: ${balance} BTC)...`); + await rpc.generateToAddress(10, address); + balance = await rpc.getBalance(); + } + console.info(`โ†’ [local] Local bitcoind has ${balance} BTC`); +} + +// Known regtest address for send tests (used when BACKEND=regtest) +// This is a standard regtest address that always works +const REGTEST_TEST_ADDRESS = 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'; + +/** + * Returns an external address to send funds TO (for testing send functionality). + * - BACKEND=local: generates a new address from local bitcoind + * - BACKEND=regtest: returns a known regtest test address + */ +export async function getExternalAddress(): Promise { + const backend = getBackend(); + if (backend === 'local') { + const rpc = getRpc(); + return rpc.getNewAddress(); + } + return REGTEST_TEST_ADDRESS; +} + +/** + * Sends funds to an address (for testing receive in the app). + * - BACKEND=local: uses local bitcoind RPC + * - BACKEND=regtest: uses Blocktank deposit API + * + * @param address - The address to send to + * @param amountBtcOrSats - Amount (BTC string for local, sats number for regtest) + */ +export async function sendToAddress( + address: string, + amountBtcOrSats: string | number +): Promise { + const backend = getBackend(); + if (backend === 'local') { + const rpc = getRpc(); + const btc = + typeof amountBtcOrSats === 'number' + ? (amountBtcOrSats / 100_000_000).toString() + : amountBtcOrSats; + return rpc.sendToAddress(address, btc); + } else { + const sats = + typeof amountBtcOrSats === 'string' + ? Math.round(parseFloat(amountBtcOrSats) * 100_000_000) + : amountBtcOrSats; + return blocktankDeposit(address, sats); + } +} + +/** + * Deposits satoshis to an address on regtest. + * Uses local Bitcoin RPC or Blocktank API based on BACKEND env var. + * + * @param address - The Bitcoin address to fund + * @param amountSat - Amount in satoshis (optional) + * @returns The transaction ID + */ +export async function deposit(address: string, amountSat?: number): Promise { + const backend = getBackend(); + if (backend === 'local') { + return localDeposit(address, amountSat); + } else { + return blocktankDeposit(address, amountSat); + } +} + +/** + * Mines blocks on regtest. + * Uses local Bitcoin RPC or Blocktank API based on BACKEND env var. + * + * @param count - Number of blocks to mine (default: 1) + */ +export async function mineBlocks(count: number = 1): Promise { + const backend = getBackend(); + if (backend === 'local') { + return localMineBlocks(count); + } else { + return blocktankMineBlocks(count); + } +} + +/** + * Pays a Lightning invoice on regtest. + * Only available with Blocktank backend (regtest). + * + * @param invoice - The BOLT11 invoice to pay + * @param amountSat - Amount in satoshis (optional, for amount-less invoices) + * @returns The payment ID + */ +export async function payInvoice(invoice: string, amountSat?: number): Promise { + const backend = getBackend(); + if (backend === 'local') { + throw new Error('payInvoice is only available with BACKEND=regtest (Blocktank API)'); + } + return blocktankPayInvoice(invoice, amountSat); +} diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 911b7a0..488f0d0 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -27,18 +27,18 @@ export async function reinstallApp() { } export function getRnAppPath(): string { - const fallback = path.join(__dirname, '..', '..', 'aut', 'bitkit_rn_regtest.apk'); + const appFileName = driver.isIOS ? 'bitkit_rn_regtest_ios.app' : 'bitkit_rn_regtest.apk'; + const fallback = path.join(__dirname, '..', '..', 'aut', appFileName); const appPath = process.env.RN_APK_PATH ?? fallback; if (!fs.existsSync(appPath)) { - throw new Error( - `RN APK not found at: ${appPath}. Set RN_APK_PATH or place it at ${fallback}` - ); + throw new Error(`RN APK not found at: ${appPath}. Set RN_APK_PATH or place it at ${fallback}`); } return appPath; } export function getNativeAppPath(): string { - const fallback = path.join(__dirname, '..', '..', 'aut', 'bitkit_e2e.apk'); + const appFileName = driver.isIOS ? 'bitkit.app' : 'bitkit_e2e.apk'; + const fallback = path.join(__dirname, '..', '..', 'aut', appFileName); const appPath = process.env.NATIVE_APK_PATH ?? fallback; if (!fs.existsSync(appPath)) { throw new Error( @@ -61,7 +61,7 @@ export async function reinstallAppFromPath(appPath: string, appId: string = getA * (Wallet data is stored in iOS Keychain and persists even after app uninstall * unless the whole simulator is reset or keychain is reset specifically) */ -function resetBootedIOSKeychain() { +export function resetBootedIOSKeychain() { if (!driver.isIOS) return; let udid = ''; diff --git a/test/specs/backup.e2e.ts b/test/specs/backup.e2e.ts index 3ea60d6..2d5c9ca 100644 --- a/test/specs/backup.e2e.ts +++ b/test/specs/backup.e2e.ts @@ -1,5 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; -import { bitcoinURL } from '../helpers/constants'; import initElectrum from '../helpers/electrum'; import { reinstallApp } from '../helpers/setup'; import { @@ -19,21 +17,13 @@ import { waitForBackup, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; +import { ensureLocalFunds } from '../helpers/regtest'; describe('@backup - Backup', () => { let electrum: Awaited> | undefined; - const rpc = new BitcoinJsonRpc(bitcoinURL); before(async () => { - // ensure we have at least 10 BTC on regtest - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -57,7 +47,7 @@ describe('@backup - Backup', () => { // - check if everything was restored // - receive some money // - await receiveOnchainFunds(rpc, { sats: 100_000_000, expectHighBalanceWarning: true }); + await receiveOnchainFunds({ sats: 100_000_000, expectHighBalanceWarning: true }); // - set tag // const tag = 'testtag'; diff --git a/test/specs/boost.e2e.ts b/test/specs/boost.e2e.ts index 69f7253..7fb276e 100644 --- a/test/specs/boost.e2e.ts +++ b/test/specs/boost.e2e.ts @@ -1,5 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; - import { sleep, completeOnboarding, @@ -12,7 +10,6 @@ import { expectTextWithin, elementByIdWithin, getTextUnder, - mineBlocks, doNavigationClose, getSeed, waitForBackup, @@ -20,24 +17,16 @@ import { enterAddress, waitForToast, } from '../helpers/actions'; -import { bitcoinURL } from '../helpers/constants'; import initElectrum from '../helpers/electrum'; import { reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; +import { ensureLocalFunds, getExternalAddress, mineBlocks } from '../helpers/regtest'; describe('@boost - Boost', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); before(async () => { - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -52,7 +41,7 @@ describe('@boost - Boost', () => { ciIt('@boost_1 - Can do CPFP', async () => { // fund the wallet (100 000), don't mine blocks so tx is unconfirmed - await receiveOnchainFunds(rpc, { sats: 100_000, blocksToMine: 0 }); + await receiveOnchainFunds({ sats: 100_000, blocksToMine: 0 }); // check Activity await swipeFullScreen('up'); @@ -125,7 +114,7 @@ describe('@boost - Boost', () => { await elementById('StatusBoosting').waitForDisplayed(); // mine new block - await mineBlocks(rpc, 1); + await mineBlocks(1); await doNavigationClose(); await sleep(500); @@ -142,10 +131,10 @@ describe('@boost - Boost', () => { ciIt('@boost_2 - Can do RBF', async () => { // fund the wallet (100 000) - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); // Send 10 000 - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); await enterAddress(coreAddress); await tap('N1'); await tap('N0'); @@ -231,7 +220,7 @@ describe('@boost - Boost', () => { await doNavigationClose(); // mine new block - await mineBlocks(rpc, 1); + await mineBlocks(1); await doNavigationClose(); await sleep(500); diff --git a/test/specs/lightning.e2e.ts b/test/specs/lightning.e2e.ts index b6e4db7..3671f15 100644 --- a/test/specs/lightning.e2e.ts +++ b/test/specs/lightning.e2e.ts @@ -1,4 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; import initElectrum from '../helpers/electrum'; import { completeOnboarding, @@ -19,7 +18,6 @@ import { getAddressFromQRCode, getSeed, restoreWallet, - mineBlocks, elementByText, dismissQuickPayIntro, doNavigationClose, @@ -29,7 +27,7 @@ import { waitForToast, } from '../helpers/actions'; import { reinstallApp } from '../helpers/setup'; -import { bitcoinURL, lndConfig } from '../helpers/constants'; +import { lndConfig } from '../helpers/constants'; import { connectToLND, getLDKNodeID, @@ -40,20 +38,16 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; +import { ensureLocalFunds, getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@lightning - Lightning', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + // LND tests only work with BACKEND=local + let rpc: ReturnType; before(async () => { - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + rpc = getBitcoinRpc(); + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -77,7 +71,7 @@ describe('@lightning - Lightning', () => { // - check balances, tx history and notes // - close channel - await receiveOnchainFunds(rpc, { sats: 1000 }); + await receiveOnchainFunds({ sats: 1000 }); // send funds to LND node and open a channel const { lnd, lndNodeID } = await setupLND(rpc, lndConfig); @@ -293,7 +287,7 @@ describe('@lightning - Lightning', () => { await elementByText('Transfer Initiated').waitForDisplayed(); await elementByText('Transfer Initiated').waitForDisplayed({ reverse: true }); - await mineBlocks(rpc, 6); + await mineBlocks(6); await electrum?.waitForSync(); await elementById('Channel').waitForDisplayed({ reverse: true }); if (driver.isAndroid) { diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index faf7591..1ba6586 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -1,8 +1,7 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; import LNURL from 'lnurl'; import initElectrum from '../helpers/electrum'; -import { bitcoinURL, lndConfig } from '../helpers/constants'; +import { lndConfig } from '../helpers/constants'; import { sleep, tap, @@ -34,6 +33,7 @@ import { waitForActiveChannel, setupLND, } from '../helpers/lnd'; +import { ensureLocalFunds, getBitcoinRpc, mineBlocks } from '../helpers/regtest'; function waitForEvent(lnurlServer: any, name: string): Promise { let timer: NodeJS.Timeout | undefined; @@ -57,17 +57,12 @@ function waitForEvent(lnurlServer: any, name: string): Promise { describe('@lnurl - LNURL', () => { let electrum: Awaited> | undefined; let lnurlServer: any; - const rpc = new BitcoinJsonRpc(bitcoinURL); + // LND tests only work with BACKEND=local + let rpc: ReturnType; before(async () => { - // Ensure we have at least 10 BTC on regtest - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + rpc = getBitcoinRpc(); + await ensureLocalFunds(); electrum = await initElectrum(); // Start local LNURL server backed by LND REST @@ -105,7 +100,7 @@ describe('@lnurl - LNURL', () => { ciIt( '@lnurl_1 - Can process lnurl-channel, lnurl-pay, lnurl-withdraw, and lnurl-auth', async () => { - await receiveOnchainFunds(rpc, { sats: 1000 }); + await receiveOnchainFunds({ sats: 1000 }); // Get LDK node id from the UI const ldkNodeID = await getLDKNodeID(); @@ -134,7 +129,7 @@ describe('@lnurl - LNURL', () => { await waitForPeerConnection(lnd as any, ldkNodeID); // Confirm channel by mining and syncing - await rpc.generateToAddress(6, await rpc.getNewAddress()); + await mineBlocks(6); await electrum?.waitForSync(); // Wait for channel to be active diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 8d03450..aa25a56 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,47 +1,570 @@ -import { elementById, restoreWallet, sleep, tap, typeText, waitForSetupWalletScreenFinish } from '../helpers/actions'; +import { + confirmInputOnKeyboard, + dismissBackupTimedSheet, + doNavigationClose, + dragOnElement, + elementById, + elementByIdWithin, + enterAddress, + expectText, + expectTextWithin, + getAccessibleText, + getReceiveAddress, + handleAndroidAlert, + restoreWallet, + sleep, + swipeFullScreen, + tap, + typeText, + waitForSetupWalletScreenFinish, +} from '../helpers/actions'; import { ciIt } from '../helpers/suite'; -import { getNativeAppPath, getRnAppPath, reinstallAppFromPath } from '../helpers/setup'; +import { + getNativeAppPath, + getRnAppPath, + reinstallAppFromPath, + resetBootedIOSKeychain, +} from '../helpers/setup'; +import { getAppId } from '../helpers/constants'; +import initElectrum, { ElectrumClient } from '../helpers/electrum'; +import { deposit, ensureLocalFunds, getExternalAddress, mineBlocks } from '../helpers/regtest'; -const MIGRATION_MNEMONIC = - process.env.MIGRATION_MNEMONIC ?? - 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +// Module-level electrum client (set in before hook) +let electrumClient: ElectrumClient; -describe('@migration - Legacy RN migration', () => { - ciIt('@migration_1 - Can restore legacy RN wallet from mnemonic', async () => { - await installLegacyRnApp(); - await restoreLegacyRnWallet(MIGRATION_MNEMONIC); +// ============================================================================ +// MIGRATION TEST CONFIGURATION +// ============================================================================ - // Restore into native app - // await installNativeApp(); - await restoreWallet(MIGRATION_MNEMONIC); +// Tags used for testing migration +const TAG_RECEIVED = 'received'; +const TAG_SENT = 'sent'; + +// Amounts for testing +const INITIAL_FUND_SATS = 500_000; // 500k sats initial funding +const ONCHAIN_SEND_SATS = 50_000; // 50k sats for on-chain send test +const TRANSFER_TO_SPENDING_SATS = 100_000; // 100k for creating a channel + +// Passphrase for passphrase-protected wallet tests +const TEST_PASSPHRASE = 'supersecret'; + +// ============================================================================ +// TEST SUITE +// ============================================================================ + +describe('@migration - Migration from legacy RN app to native app', () => { + before(async () => { + await ensureLocalFunds(); + electrumClient = await initElectrum(); + }); + + after(async () => { + await electrumClient?.stop(); + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 1: Uninstall RN, install Native, restore mnemonic + // -------------------------------------------------------------------------- + ciIt('@migration_1 - Uninstall RN, install Native, restore mnemonic', async () => { + // Setup wallet in RN app and get mnemonic + const mnemonic = await setupLegacyWallet({ returnSeed: true }); + + // Uninstall RN app + console.info('โ†’ Removing legacy RN app...'); + await driver.removeApp(getAppId()); + resetBootedIOSKeychain(); + + // Install native app + console.info(`โ†’ Installing native app from: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // Restore wallet with mnemonic (uses custom flow to handle backup sheet) + await restoreWallet(mnemonic!, { reinstall: false, expectBackupSheet: true }); + + // Verify migration + await verifyMigration(); + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 2: Install native on top of RN (upgrade) + // -------------------------------------------------------------------------- + ciIt('@migration_2 - Install native on top of RN (upgrade)', async () => { + // Setup wallet in RN app + await setupLegacyWallet(); + + // Install native app ON TOP of RN (upgrade) + console.info(`โ†’ Installing native app on top of RN: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // Handle migration flow + await handleAndroidAlert(); + await dismissBackupTimedSheet(); + + // Verify migration + await verifyMigration(); + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 3: Uninstall RN, install Native, restore with passphrase + // -------------------------------------------------------------------------- + ciIt('@migration_3 - Uninstall RN, install Native, restore with passphrase', async () => { + // Setup wallet in RN app WITH passphrase and get mnemonic + const mnemonic = await setupLegacyWallet({ passphrase: TEST_PASSPHRASE, returnSeed: true }); + + // Uninstall RN app + console.info('โ†’ Removing legacy RN app...'); + await driver.removeApp(getAppId()); + resetBootedIOSKeychain(); + + // Install native app + console.info(`โ†’ Installing native app from: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // Restore wallet with mnemonic AND passphrase + await restoreWallet(mnemonic!, { + reinstall: false, + expectBackupSheet: true, + passphrase: TEST_PASSPHRASE, + }); + + // Verify migration + await verifyMigration(); + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 4: Install native on top of RN with passphrase (upgrade) + // -------------------------------------------------------------------------- + ciIt('@migration_4 - Install native on top of RN with passphrase (upgrade)', async () => { + // Setup wallet in RN app WITH passphrase + await setupLegacyWallet({ passphrase: TEST_PASSPHRASE }); + + // Install native app ON TOP of RN (upgrade) + console.info(`โ†’ Installing native app on top of RN: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // Handle migration flow + await handleAndroidAlert(); + await dismissBackupTimedSheet(); + + // Verify migration + await verifyMigration(); }); }); -async function installNativeApp() { - await reinstallAppFromPath(getNativeAppPath()); +// ============================================================================ +// WALLET SETUP HELPERS (RN App) +// ============================================================================ + +/** + * Complete wallet setup in legacy RN app: + * 1. Create new wallet (optionally with passphrase) + * 2. Fund with on-chain tx (add tag to latest tx) + * 3. Send on-chain tx (add tag to latest tx) + * 4. Transfer to spending balance (create channel via Blocktank) + * + * @param options.passphrase - Optional passphrase for the wallet + * @param options.returnSeed - If true, returns the mnemonic seed + * @returns The mnemonic seed if returnSeed is true, otherwise undefined + */ +async function setupLegacyWallet( + options: { + passphrase?: string; + returnSeed?: boolean; + } = {} +): Promise { + const { passphrase, returnSeed } = options; + console.info(`=== Setting up legacy RN wallet${passphrase ? ' (with passphrase)' : ''} ===`); + + // Install and create wallet + await installLegacyRnApp(); + await createLegacyRnWallet({ passphrase }); + + // 1. Fund wallet (receive on-chain) + console.info('โ†’ Step 1: Funding wallet on-chain...'); + await fundRnWallet(INITIAL_FUND_SATS); + await tagLatestTransaction(TAG_RECEIVED); + + // 2. Send on-chain tx with tag + console.info('โ†’ Step 2: Sending on-chain tx...'); + await sendRnOnchain(ONCHAIN_SEND_SATS); + await tagLatestTransaction(TAG_SENT); + + // 3. Transfer to spending (create channel via Blocktank) + console.info('โ†’ Step 3: Creating spending balance (channel)...'); + await transferToSpending(TRANSFER_TO_SPENDING_SATS); + + console.info('=== Legacy wallet setup complete ==='); + + // Get mnemonic if requested + if (returnSeed) { + const mnemonic = await getRnMnemonic(); + await sleep(1000); + return mnemonic; + } } -async function installLegacyRnApp() { + +async function installLegacyRnApp(): Promise { + console.info(`โ†’ Installing legacy RN app from: ${getRnAppPath()}`); await reinstallAppFromPath(getRnAppPath()); } -async function restoreLegacyRnWallet(seed: string) { +async function createLegacyRnWallet(options: { passphrase?: string } = {}): Promise { + const { passphrase } = options; + console.info( + `โ†’ Creating new wallet in legacy RN app${passphrase ? ' (with passphrase)' : ''}...` + ); + await elementById('Continue').waitForDisplayed(); await tap('Check1'); await tap('Check2'); await tap('Continue'); - await tap('SkipIntro'); - await tap('RestoreWallet'); - await tap('MultipleDevices-button'); - - await typeText('Word-0', seed); - await sleep(1500); - await tap('RestoreButton'); + // Set passphrase if provided (before creating wallet) + if (passphrase) { + console.info('โ†’ Setting passphrase...'); + await tap('Passphrase'); + await typeText('PassphraseInput', passphrase); + await confirmInputOnKeyboard(); + await tap('CreateNewWallet'); + } else { + // Create new wallet + await tap('NewWallet'); + } await waitForSetupWalletScreenFinish(); - const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed( { timeout: 120000 }); - await tap('GetStartedButton'); + // Wait for wallet to be created + for (let i = 1; i <= 3; i++) { + try { + await tap('WalletOnboardingClose'); + break; + } catch { + if (i === 3) throw new Error('Tapping "WalletOnboardingClose" timeout'); + } + } + console.info('โ†’ Legacy RN wallet created'); +} + +// ============================================================================ +// RN APP INTERACTION HELPERS +// ============================================================================ + +/** + * Get receive address from RN app (uses existing helper) + */ +async function getRnReceiveAddress(): Promise { + const address = await getReceiveAddress('bitcoin'); + console.info(`โ†’ RN receive address: ${address}`); + await swipeFullScreen('down'); // close receive sheet + return address; +} + +/** + * Fund RN wallet with on-chain tx + */ +async function fundRnWallet(sats: number): Promise { + const address = await getRnReceiveAddress(); + + // Deposit and mine + await deposit(address, sats); + await mineBlocks(1); + await electrumClient?.waitForSync(); + + // Wait for balance to appear + await sleep(3000); + const expectedBalance = sats.toLocaleString('en').replace(/,/g, ' '); + await expectText(expectedBalance, { strategy: 'contains' }); + console.info(`โ†’ Received ${sats} sats`); + + // Ensure we're back on main screen (dismiss any sheets/modals) + await swipeFullScreen('down'); + await sleep(500); +} + +/** + * Send on-chain tx from RN wallet and add a tag. + * Note: This uses a custom flow for RN since camera permission is already granted from receive. + */ +async function sendRnOnchain(sats: number): Promise { + const externalAddress = await getExternalAddress(); + + // RN-specific send flow (camera permission already granted during receive) + await tap('Send'); + await sleep(1000); + + // Tap manual address entry (skip camera since permission already granted) + await elementById('RecipientManual').waitForDisplayed(); + await tap('RecipientManual'); + + // Enter address + await elementById('RecipientInput').waitForDisplayed(); + await typeText('RecipientInput', externalAddress); + await confirmInputOnKeyboard(); + await sleep(500); + await tap('AddressContinue'); + + // Enter amount + await sleep(500); + const satsStr = String(sats); + for (const digit of satsStr) { + await tap(`N${digit}`); + } + await tap('ContinueAmount'); + + // Send using swipe gesture + console.info(`โ†’ About to send ${sats} sats...`); + await dragOnElement('GRAB', 'right', 0.95); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + await sleep(2000); + + // Mine and sync + await mineBlocks(1); + await electrumClient?.waitForSync(); + await sleep(1000); + await dismissSheet(); + console.info(`โ†’ Sent ${sats} sats`); +} + +/** + * Transfer savings to spending balance (create channel via Blocktank) + */ +async function transferToSpending(sats: number): Promise { + // Navigate via ActivitySavings -> TransferToSpending + try { + await elementById('ActivitySavings').waitForDisplayed({ timeout: 5000 }); + } catch { + console.info('โ†’ Scrolling to find ActivitySavings...'); + await swipeFullScreen('down', { downEndYPercent: 0.6 }); + await swipeFullScreen('down'); + } + await tap('ActivitySavings'); + await elementById('TransferToSpending').waitForDisplayed(); + await tap('TransferToSpending'); + + // Handle intro screen if shown + await sleep(1000); + await tap('SpendingIntro-button'); // "Get Started" + await sleep(1000); // let animation finish + + // Enter amount + const satsStr = String(sats); + for (const digit of satsStr) { + await tap(`N${digit}`); + } + await tap('SpendingAmountContinue'); + + // Confirm screen - swipe to transfer (no intermediate button needed) + await sleep(1000); + await dragOnElement('GRAB', 'right', 0.95); + + // Handle notification permission dialog if shown + await sleep(1000); + try { + const allowButton = await $('android=new UiSelector().text("Allow")'); + await allowButton.waitForDisplayed({ timeout: 5000 }); + await allowButton.click(); + } catch { + // Dialog might not appear, that's fine + } + + // RN shows "IN TRANSFER" screen - tap "Continue Using Bitkit" to dismiss and let it run in background + await sleep(2000); + try { + const continueButton = await $('android=new UiSelector().textContains("Continue")'); + await continueButton.waitForDisplayed({ timeout: 10000 }); + await continueButton.click(); + console.info('โ†’ Dismissed transfer screen, continuing in background...'); + } catch { + // Screen might have auto-dismissed + } + + // Mine blocks periodically to progress the channel opening + console.info('โ†’ Mining blocks to confirm channel...'); + for (let i = 0; i < 10; i++) { + await mineBlocks(1); + // Check if spending balance shows the transferred amount (transfer complete) + try { + const expectedBalance = sats.toLocaleString('en').replace(/,/g, ' '); + await expectText(expectedBalance); + break; + } catch { + // Still waiting + await sleep(3000); + } + } + + await electrumClient?.waitForSync(); + await sleep(3000); + await dismissSheet(); + console.info(`โ†’ Created spending balance with ${sats} sats`); +} + +/** + * Tag the latest (most recent) transaction in the activity list + */ +async function tagLatestTransaction(tag: string): Promise { + // Go to activity + await sleep(1000); + try { + await swipeFullScreen('up', { upStartYPercent: 0.6 }); + await swipeFullScreen('up', { upStartYPercent: 0.6 }); + await elementById('ActivityShort-1').waitForDisplayed({ timeout: 5000 }); + } catch { + console.info('โ†’ Scrolling to find latest transaction...'); + await swipeFullScreen('up'); + await swipeFullScreen('up'); + } + await tap('ActivityShort-1'); // latest tx + + // Add tag + await tap('ActivityTag'); + await elementById('TagInput').waitForDisplayed(); + const tagInput = await elementById('TagInput'); + await tagInput.click(); // Focus the input + await sleep(300); + // Use addValue to type (triggers RN onChangeText properly) + await tagInput.addValue(tag); + await sleep(300); + // Press Enter key to submit (keycode 66 = KEYCODE_ENTER) + await driver.pressKeyCode(66); + // Wait for tag sheet to close and return to Review screen + await sleep(1000); + + // Go back + await driver.back(); + await swipeFullScreen('down', { downEndYPercent: 0.6 }); + await swipeFullScreen('down', { downEndYPercent: 0.6 }); + console.info(`โ†’ Tagged latest transaction with "${tag}"`); +} + +/** + * Get mnemonic from RN wallet settings + */ +async function getRnMnemonic(): Promise { + // Navigate to backup settings + try { + await tap('HeaderMenu'); + await sleep(500); // Wait for drawer to open + await elementById('DrawerSettings').waitForDisplayed({ timeout: 5000 }); + } catch { + console.info('โ†’ Drawer did not open, trying again...'); + await tap('HeaderMenu'); + await sleep(500); // Wait for drawer to open + await elementById('DrawerSettings').waitForDisplayed({ timeout: 5000 }); + } + + await tap('DrawerSettings'); + await elementById('BackupSettings').waitForDisplayed(); + await tap('BackupSettings'); + + // Tap "Backup Wallet" to show mnemonic screen + await elementById('BackupWallet').waitForDisplayed(); + await tap('BackupWallet'); + + // Show seed (note: typo in RN code is "SeedContaider") + await elementById('SeedContaider').waitForDisplayed(); + const seedElement = await elementById('SeedContaider'); + const seed = await getAccessibleText(seedElement); + + if (!seed) throw new Error('Could not read seed from "SeedContaider"'); + console.info(`โ†’ RN mnemonic retrieved: ${seed}`); + await swipeFullScreen('down'); // close mnemonic sheet + // wait for backup to be performed + await sleep(10000); + + // Navigate back to main screen using Android back button + // ShowMnemonic -> BackupSettings -> Settings -> Main + await driver.back(); + await sleep(300); + await driver.back(); + await sleep(300); + await driver.back(); + await sleep(500); + + return seed; +} + +// ============================================================================ +// MIGRATION VERIFICATION +// ============================================================================ + +/** + * Verify migration was successful (basic version - just checks balance) + */ +async function verifyMigration(): Promise { + console.info('=== Verifying migration ==='); + + // Verify we have balance (should match what we funded) + const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + const balanceText = await totalBalanceEl.getText(); + console.info(`โ†’ Total balance: ${balanceText}`); + + // Basic check - we should have funds + const balanceNum = parseInt(balanceText.replace(/\s/g, ''), 10); + if (balanceNum <= 0) { + throw new Error(`Expected positive balance, got: ${balanceText}`); + } + console.info('โ†’ Balance migrated successfully'); + + // Go to activity list to verify transactions exist + await swipeFullScreen('up'); + await swipeFullScreen('up'); + await tap('ActivityShowAll'); + + // All transactions (Transfer, Sent, Received = 3 items) + await expectTextWithin('Activity-1', '-'); // Transfer (spending) + await expectTextWithin('Activity-2', '-'); // Sent + await expectTextWithin('Activity-3', '+'); // Received + + // Sent tab: should show Sent tx only (not Transfer) + await tap('Tab-sent'); + await expectTextWithin('Activity-1', '-'); + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + + // Received tab: should show Received tx only + await tap('Tab-received'); + await expectTextWithin('Activity-1', '+'); + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + + // Other tab: should show Transfer (spending) tx + await tap('Tab-other'); + await elementById('Activity-1').waitForDisplayed(); + await expectTextWithin('Activity-1', '-'); // Transfer shows here + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + + // filter by receive tag + await tap('Tab-all'); + await tap('TagsPrompt'); + await sleep(500); + await tap(`Tag-${TAG_RECEIVED}`); + await expectTextWithin('Activity-1', '+'); // Only received tx has this tag + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + await tap(`Tag-${TAG_RECEIVED}-delete`); + + // filter by send tag + await tap('TagsPrompt'); + await sleep(500); + await tap(`Tag-${TAG_SENT}`); + await expectTextWithin('Activity-1', '-'); // Only sent tx has this tag (not Transfer) + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + await tap(`Tag-${TAG_SENT}-delete`); + + console.info('โ†’ Activity tags migrated successfully'); + console.info('โ†’ Transaction history migrated successfully'); + + await doNavigationClose(); + + console.info('=== Migration verified successfully ==='); +} + +async function dismissSheet(): Promise { + //dismiss a sheet if shown await sleep(1000); + await swipeFullScreen('down', { downEndYPercent: 0.6 }); + await sleep(2000); } diff --git a/test/specs/onchain.e2e.ts b/test/specs/onchain.e2e.ts index 787d701..55a0b74 100644 --- a/test/specs/onchain.e2e.ts +++ b/test/specs/onchain.e2e.ts @@ -1,5 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; -import { bitcoinURL } from '../helpers/constants'; import initElectrum from '../helpers/electrum'; import { reinstallApp } from '../helpers/setup'; import { @@ -12,7 +10,6 @@ import { expectTextWithin, doNavigationClose, getReceiveAddress, - mineBlocks, multiTap, sleep, swipeFullScreen, @@ -27,21 +24,18 @@ import { acknowledgeReceivedPayment, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; +import { + ensureLocalFunds, + getExternalAddress, + mineBlocks, + sendToAddress, +} from '../helpers/regtest'; describe('@onchain - Onchain', () => { let electrum: Awaited> | undefined; - const rpc = new BitcoinJsonRpc(bitcoinURL); before(async () => { - // ensure we have at least 10 BTC on regtest - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -57,10 +51,10 @@ describe('@onchain - Onchain', () => { ciIt('@onchain_1 - Receive and send some out', async () => { // receive some first - await receiveOnchainFunds(rpc, { sats: 100_000_000, expectHighBalanceWarning: true }); + await receiveOnchainFunds({ sats: 100_000_000, expectHighBalanceWarning: true }); // then send out 10 000 - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); console.info({ coreAddress }); await enterAddress(coreAddress); await tap('N1'); @@ -72,7 +66,7 @@ describe('@onchain - Onchain', () => { await elementById('SendSuccess').waitForDisplayed(); await tap('Close'); - await mineBlocks(rpc, 1); + await mineBlocks(1); await electrum?.waitForSync(); const moneyTextAfter = (await elementsById('MoneyText'))[1]; @@ -124,10 +118,10 @@ describe('@onchain - Onchain', () => { await tap('ShowQrReceive'); await swipeFullScreen('down'); - await rpc.sendToAddress(address, '1'); + await sendToAddress(address, '1'); await acknowledgeReceivedPayment(); - await mineBlocks(rpc, 1); + await mineBlocks(1); await electrum?.waitForSync(); await sleep(1000); // wait for the app to settle @@ -143,7 +137,7 @@ describe('@onchain - Onchain', () => { } // - can send total balance and tag the tx // - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); await enterAddress(coreAddress); // Amount / NumberPad @@ -166,7 +160,7 @@ describe('@onchain - Onchain', () => { await elementById('SendSuccess').waitForDisplayed(); await tap('Close'); - await mineBlocks(rpc, 1); + await mineBlocks(1); const totalBalance = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); await expect(totalBalance).toHaveText('0'); @@ -258,7 +252,7 @@ describe('@onchain - Onchain', () => { ciIt('@onchain_3 - Avoids creating a dust output and instead adds it to the fee', async () => { // receive some first - await receiveOnchainFunds(rpc, { sats: 100_000_000, expectHighBalanceWarning: true }); + await receiveOnchainFunds({ sats: 100_000_000, expectHighBalanceWarning: true }); // enable warning for sending over 100$ to test multiple warning dialogs await tap('HeaderMenu'); @@ -267,7 +261,7 @@ describe('@onchain - Onchain', () => { await tap('SendAmountWarning'); await doNavigationClose(); - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); console.info({ coreAddress }); await enterAddress(coreAddress); @@ -294,7 +288,7 @@ describe('@onchain - Onchain', () => { await elementById('SendSuccess').waitForDisplayed(); await tap('Close'); - await mineBlocks(rpc, 1); + await mineBlocks(1); await electrum?.waitForSync(); const totalBalanceAfter = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); diff --git a/test/specs/security.e2e.ts b/test/specs/security.e2e.ts index db40dfb..e67e0d5 100644 --- a/test/specs/security.e2e.ts +++ b/test/specs/security.e2e.ts @@ -1,5 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; - import { sleep, completeOnboarding, @@ -13,24 +11,16 @@ import { expectText, doNavigationClose, } from '../helpers/actions'; -import { bitcoinURL } from '../helpers/constants'; import initElectrum from '../helpers/electrum'; import { launchFreshApp, reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; +import { ensureLocalFunds, getExternalAddress } from '../helpers/regtest'; describe('@security - Security And Privacy', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); before(async () => { - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -80,10 +70,10 @@ describe('@security - Security And Privacy', () => { await elementById('TotalBalance').waitForDisplayed(); // receive - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); // send, using PIN - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); await enterAddress(coreAddress); await tap('N1'); await tap('N000'); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index f7bbb27..df649e6 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -1,4 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; import { encode } from 'bip21'; import initElectrum from '../helpers/electrum'; @@ -17,7 +16,6 @@ import { swipeFullScreen, multiTap, typeAddressAndVerifyContinue, - mineBlocks, dismissQuickPayIntro, doNavigationClose, waitForToast, @@ -25,7 +23,7 @@ import { dismissBackgroundPaymentsTimedSheet, acknowledgeReceivedPayment, } from '../helpers/actions'; -import { bitcoinURL, lndConfig } from '../helpers/constants'; +import { lndConfig } from '../helpers/constants'; import { reinstallApp } from '../helpers/setup'; import { confirmInputOnKeyboard, tap, typeText } from '../helpers/actions'; import { @@ -38,20 +36,21 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; +import { + ensureLocalFunds, + getBitcoinRpc, + getExternalAddress, + mineBlocks, +} from '../helpers/regtest'; describe('@send - Send', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + // LND tests only work with BACKEND=local + let rpc: ReturnType; before(async () => { - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + rpc = getBitcoinRpc(); + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -96,14 +95,14 @@ describe('@send - Send', () => { // Receive funds and check validation w/ balance await swipeFullScreen('down'); - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); await tap('Send'); await sleep(500); await tap('RecipientManual'); // check validation for address - const address2 = await rpc.getNewAddress(); + const address2 = await getExternalAddress(); try { await typeAddressAndVerifyContinue({ address: address2 }); } catch { @@ -143,7 +142,7 @@ describe('@send - Send', () => { // - quickpay to lightning invoice // - quickpay to unified invoice - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); // send funds to LND node and open a channel const { lnd, lndNodeID } = await setupLND(rpc, lndConfig); @@ -356,7 +355,9 @@ describe('@send - Send', () => { await expectTextWithin('ActivitySpending', '7 000'); } else { // https://github.com/synonymdev/bitkit-ios/issues/300 - console.info('Skipping sending to unified invoice w/ expired invoice on iOS due to /bitkit-ios/issues/300'); + console.info( + 'Skipping sending to unified invoice w/ expired invoice on iOS due to /bitkit-ios/issues/300' + ); amtAfterUnified3 = amtAfterUnified2; } @@ -396,6 +397,7 @@ describe('@send - Send', () => { await sleep(1000); await enterAddress(unified5, { acceptCameraPermission: false }); // max amount (lightning) + await sleep(500); await tap('AvailableAmount'); await tap('ContinueAmount'); await expectText('4 998', { strategy: 'contains' }); @@ -404,6 +406,7 @@ describe('@send - Send', () => { await tap('NavigationBack'); // max amount (onchain) await tap('AssetButton-switch'); + await sleep(500); await tap('AvailableAmount'); if (driver.isIOS) { // iOS runs an autopilot coin selection step on Continue; when the amount is the true "max" @@ -471,7 +474,7 @@ describe('@send - Send', () => { // TEMP: receive more funds to be able to pay 10k invoice console.info('Receiving lightning funds...'); - await mineBlocks(rpc, 1); + await mineBlocks(1); await electrum?.waitForSync(); const receive2 = await getReceiveAddress('lightning'); await swipeFullScreen('down'); diff --git a/test/specs/transfer.e2e.ts b/test/specs/transfer.e2e.ts index e04d0d8..42b6081 100644 --- a/test/specs/transfer.e2e.ts +++ b/test/specs/transfer.e2e.ts @@ -1,5 +1,3 @@ -import BitcoinJsonRpc from 'bitcoin-json-rpc'; - import initElectrum from '../helpers/electrum'; import { completeOnboarding, @@ -14,7 +12,6 @@ import { dragOnElement, expectTextWithin, swipeFullScreen, - mineBlocks, elementByIdWithin, enterAddress, dismissQuickPayIntro, @@ -33,24 +30,20 @@ import { waitForActiveChannel, waitForPeerConnection, } from '../helpers/lnd'; -import { bitcoinURL, lndConfig } from '../helpers/constants'; +import { lndConfig } from '../helpers/constants'; +import { ensureLocalFunds, getBitcoinRpc, mineBlocks } from '../helpers/regtest'; import { launchFreshApp, reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; describe('@transfer - Transfer', () => { let electrum: { waitForSync: () => any; stop: () => void }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + // LND tests only work with BACKEND=local + let rpc: ReturnType; before(async () => { - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - + rpc = getBitcoinRpc(); + await ensureLocalFunds(); electrum = await initElectrum(); }); @@ -77,7 +70,7 @@ describe('@transfer - Transfer', () => { ciIt( '@transfer_1 - Can buy a channel from Blocktank with default and custom receive capacity', async () => { - await receiveOnchainFunds(rpc, { sats: 1000_000, expectHighBalanceWarning: true }); + await receiveOnchainFunds({ sats: 1000_000, expectHighBalanceWarning: true }); // switch to EUR await tap('HeaderMenu'); @@ -302,7 +295,7 @@ describe('@transfer - Transfer', () => { ); ciIt('@transfer_2 - Can open a channel to external node', async () => { - await receiveOnchainFunds(rpc, { sats: 100_000 }); + await receiveOnchainFunds({ sats: 100_000 }); // send funds to LND node and open a channel const { lnd, lndNodeID } = await setupLND(rpc, lndConfig); @@ -362,7 +355,7 @@ describe('@transfer - Transfer', () => { await expectTextWithin('ActivityShort-1', 'Received'); await swipeFullScreen('down'); - await mineBlocks(rpc, 6); + await mineBlocks(6); await electrum?.waitForSync(); await waitForToast('SpendingBalanceReadyToast'); await sleep(1000); diff --git a/wdio.conf.ts b/wdio.conf.ts index 7f5dad1..c3e904a 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -65,8 +65,8 @@ export const config: WebdriverIO.Config = { 'appium:deviceName': 'Pixel_6', 'appium:platformVersion': '13.0', 'appium:app': path.join(__dirname, 'aut', 'bitkit_e2e.apk'), - // 'appium:app': path.join(__dirname, 'aut', 'bitkit_v1.1.2.apk'), 'appium:autoGrantPermissions': true, + 'appium:waitForIdleTimeout': 1000, } : { platformName: 'iOS',