diff --git a/.gitignore b/.gitignore index 2baa0fb..b94c437 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ aut node_modules docker/lnd artifacts -WARP.md \ No newline at end of file +WARP.md +.ai \ No newline at end of file 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..73ca65e 100755 --- a/scripts/build-rn-android-apk.sh +++ b/scripts/build-rn-android-apk.sh @@ -17,7 +17,7 @@ set -euo pipefail E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" RN_ROOT="$(cd "$E2E_ROOT/../bitkit" && pwd)" -BUILD_TYPE="${1:-debug}" +BUILD_TYPE="${1:-release}" BACKEND="${BACKEND:-regtest}" if [[ "$BUILD_TYPE" != "debug" && "$BUILD_TYPE" != "release" ]]; then @@ -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..24d7585 --- /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:-release}" +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/scripts/mine-blocks.sh b/scripts/mine-blocks.sh new file mode 100755 index 0000000..d6e48d4 --- /dev/null +++ b/scripts/mine-blocks.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Mine blocks on regtest via Blocktank API +# +# Usage: +# ./scripts/mine-blocks.sh [count] +# +# Examples: +# ./scripts/mine-blocks.sh # mines 1 block +# ./scripts/mine-blocks.sh 6 # mines 6 blocks +# + +set -e + +COUNT="${1:-1}" +BLOCKTANK_URL="${BLOCKTANK_URL:-https://api.stag0.blocktank.to/blocktank/api/v2}" +ENDPOINT="${BLOCKTANK_URL}/regtest/chain/mine" + +echo "โ†’ Mining ${COUNT} block(s) via Blocktank..." + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \ + -H "Content-Type: application/json" \ + -d "{\"count\": ${COUNT}}") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "โœ“ Mined ${COUNT} block(s)" +else + echo "โœ— Failed to mine blocks: HTTP $HTTP_CODE" + echo "$BODY" + exit 1 +fi diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f1eb58b..de2ab73 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,24 @@ 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, + durationMs = 200, + pauseMs = 50, + }: { + downStartYPercent?: number; + downEndYPercent?: number; + upStartYPercent?: number; + upEndYPercent?: number; + durationMs?: number; + pauseMs?: number; + } = {} +) { const { width, height } = await driver.getWindowSize(); let startX = width / 2; @@ -330,12 +347,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; } @@ -347,8 +364,8 @@ export async function swipeFullScreen(direction: Direction) { actions: [ { type: 'pointerMove', duration: 0, x: startX, y: startY }, { type: 'pointerDown', button: 0 }, - { type: 'pause', duration: 50 }, - { type: 'pointerMove', duration: 200, x: endX, y: endY }, + { type: 'pause', duration: pauseMs }, + { type: 'pointerMove', duration: durationMs, x: endX, y: endY }, { type: 'pointerUp', button: 0 }, ], }, @@ -522,22 +539,33 @@ 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(); await sleep(1000); // Wait for the app to settle await tap('Continue'); - + await sleep(500); // Skip intro await tap('SkipIntro'); + await sleep(500); await tap('RestoreWallet'); await tap('MultipleDevices-button'); @@ -559,15 +587,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(); } @@ -584,7 +614,7 @@ export async function getReceiveAddress(which: addressType = 'bitcoin'): Promise return getAddressFromQRCode(which); } -export async function getAddressFromQRCode(which: addressType): Promise { +export async function getUriFromQRCode(): Promise { const qrCode = await elementById('QRCode'); await qrCode.waitForDisplayed(); let uri = ''; @@ -605,16 +635,28 @@ export async function getAddressFromQRCode(which: addressType): Promise } ); console.info({ uri }); + return uri; +} +export async function getAddressFromQRCode(which: addressType): Promise { + const uri = await getUriFromQRCode(); let address = ''; if (which === 'bitcoin') { address = uri.replace(/^bitcoin:/, '').replace(/\?.*$/, ''); - // Accept Bech32 HRPs across networks: mainnet (bc1), testnet/signet (tb1), regtest (bcrt1) - const allowedBitcoinHrp = ['bc1', 'tb1', 'bcrt1']; - const addrLower = address.toLowerCase(); - if (!allowedBitcoinHrp.some((p) => addrLower.startsWith(p))) { + // Accept addresses across networks and types: + // - Bech32 (native segwit): mainnet (bc1), testnet/signet (tb1), regtest (bcrt1) + // - Legacy P2PKH: mainnet (1), testnet/regtest (m, n) + // - P2SH: mainnet (3), testnet/regtest (2) + const allowedPrefixes = ['bc1', 'tb1', 'bcrt1', '1', '3', 'm', 'n', '2']; + const addrStart = address.charAt(0).toLowerCase(); + const isBech32 = + address.toLowerCase().startsWith('bc1') || + address.toLowerCase().startsWith('tb1') || + address.toLowerCase().startsWith('bcrt1'); + const isLegacyOrP2SH = ['1', '3', 'm', 'n', '2'].includes(addrStart); + if (!isBech32 && !isLegacyOrP2SH) { throw new Error( - `Invalid Bitcoin address HRP: ${address}. Expected one of: ${allowedBitcoinHrp.join(', ')}` + `Invalid Bitcoin address: ${address}. Expected prefix is one of: ${allowedPrefixes.join(', ')}` ); } } else if (which === 'lightning') { @@ -642,37 +684,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..2c92277 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,47 +1,949 @@ -import { elementById, restoreWallet, sleep, tap, typeText, waitForSetupWalletScreenFinish } from '../helpers/actions'; +import { + acknowledgeReceivedPayment, + confirmInputOnKeyboard, + dismissBackupTimedSheet, + doNavigationClose, + dragOnElement, + elementById, + elementByIdWithin, + expectText, + expectTextWithin, + getAccessibleText, + getReceiveAddress, + getUriFromQRCode, + handleAndroidAlert, + restoreWallet, + sleep, + swipeFullScreen, + tap, + typeText, + waitForBackup, + 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, + payInvoice, +} 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 +// ============================================================================ + +// 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 CJIT_INVOICE_SATS = 3000; // Unused - Blocktank regtest doesn't support CJIT +const TRANSFER_TO_SPENDING_SATS = 100_000; // 100k for creating a channel + +// Passphrase for passphrase-protected wallet tests +const TEST_PASSPHRASE = 'supersecret'; + +// iOS: Read mnemonic and balance from env vars (prepared by Android run) +// This is needed because RN app on iOS has poor Appium support +const IOS_RN_MNEMONIC = process.env.RN_MNEMONIC; +const IOS_RN_BALANCE = process.env.RN_BALANCE ? parseInt(process.env.RN_BALANCE, 10) : undefined; + +// ============================================================================ +// 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, balance } = await setupLegacyWallet({ returnSeed: true }); - // Restore into native app - // await installNativeApp(); - await restoreWallet(MIGRATION_MNEMONIC); + // 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(balance); + }); + + // -------------------------------------------------------------------------- + // 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 + const { mnemonic, balance } = await setupLegacyWallet({ returnSeed: true }); + + // 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 handleMigrationFlow({ withSweep: false }); + + // Verify migration + await verifyMigration(balance); + await mineBlocks(1); + if (driver.isIOS) { + // Restore wallet again to verify mnemonic restoration works post-migration + console.info('โ†’ Restoring wallet again to verify mnemonic restoration post-migration...'); + await waitForBackup(); + await restoreWallet(mnemonic!); + await verifyMigration(balance); + } + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 3: Install native on top of RN with passphrase (upgrade) + // -------------------------------------------------------------------------- + ciIt('@migration_3 - Install native on top of RN with passphrase (upgrade)', async () => { + // Setup wallet in RN app WITH passphrase + const { balance } = 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 handleMigrationFlow({ withSweep: false }); + + // Verify migration + await verifyMigration(balance); + }); + + // -------------------------------------------------------------------------- + // Migration Scenario 4: Migration with sweep (legacy p2pkh addresses) + // This scenario tests migration when wallet has funds on legacy addresses, + // which triggers a sweep flow during migration. + // -------------------------------------------------------------------------- + ciIt('@migration_4 - Migration with sweep (legacy p2pkh addresses)', async () => { + // Setup wallet with funds on legacy addresses (triggers sweep on migration) + const { balance } = await setupWalletWithLegacyFunds(); + + // 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 with sweep + await handleMigrationFlow({ withSweep: true }); + + // Verify migration completed (balance should be preserved after sweep, minus fees) + await verifyMigrationWithSweep(balance); }); }); -async function installNativeApp() { - await reinstallAppFromPath(getNativeAppPath()); +// ============================================================================ +// WALLET SETUP HELPERS (RN App) +// ============================================================================ + +type LegacyWalletSetupResult = { + mnemonic?: string; + balance: number; +}; + +/** + * Get the total balance from the RN app's home screen. + * Uses MoneyText within TotalBalance, same pattern as RN e2e tests. + */ +async function getRnTotalBalance(): Promise { + const balanceEl = await elementByIdWithin('TotalBalance', 'MoneyText'); + await balanceEl.waitForDisplayed(); + const balanceText = await balanceEl.getText(); + // Balance is formatted with spaces as thousand separators (e.g., "449 502") + const balance = parseInt(balanceText.replace(/\s/g, ''), 10); + console.info(`โ†’ RN total balance: ${balance} sats (${balanceText})`); + return balance; +} + +/** + * Complete wallet setup in legacy RN app: + * + * On Android: + * 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) + * + * On iOS: RN app has poor Appium support for wallet creation, so we restore + * from mnemonic/balance provided via env vars (RN_MNEMONIC, RN_BALANCE). + * + * @param options.passphrase - Optional passphrase for the wallet + * @param options.returnSeed - If true, returns the mnemonic seed + * @returns Object with mnemonic (if returnSeed) and final balance + */ +async function setupLegacyWallet( + options: { + passphrase?: string; + returnSeed?: boolean; + setLegacyAddress?: boolean; + } = {} +): Promise { + const { passphrase, returnSeed, setLegacyAddress } = options; + + // iOS: Restore wallet from env vars (prepared by Android run) + if (driver.isIOS) { + if (!IOS_RN_MNEMONIC || !IOS_RN_BALANCE) { + throw new Error( + 'iOS migration tests require RN_MNEMONIC and RN_BALANCE env vars. ' + + 'Run Android tests first to prepare the wallet.' + ); + } + console.info('=== iOS: Restoring RN wallet from mnemonic (prepared by Android) ==='); + console.info(`โ†’ Mnemonic: ${IOS_RN_MNEMONIC.split(' ').slice(0, 3).join(' ')}...`); + console.info(`โ†’ Expected balance: ${IOS_RN_BALANCE} sats`); + + // Install RN app and restore wallet + await installLegacyRnApp(); + await restoreRnWallet(IOS_RN_MNEMONIC, { passphrase }); + + console.info('=== iOS: RN wallet restored ==='); + return { mnemonic: IOS_RN_MNEMONIC, balance: IOS_RN_BALANCE }; + } + + // Android: Full RN wallet setup + console.info( + `=== Setting up legacy RN wallet${passphrase ? ' (with passphrase)' : ''}${setLegacyAddress ? ' (legacy address)' : ''} ===` + ); + + // Install and create wallet + await installLegacyRnApp(); + await createLegacyRnWallet({ passphrase }); + + let mnemonic: string | undefined; + if (returnSeed) { + // Get mnemonic for later restoration + mnemonic = await getRnMnemonic(); + console.info(`โ†’ Legacy RN wallet mnemonic: ${mnemonic}`); + } + + // Set legacy address type if requested (before funding) + if (setLegacyAddress) { + console.info('โ†’ Setting legacy (p2pkh) address type...'); + await setRnAddressType('p2pkh'); + } + + // 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); + + // Get final balance before migration + const balance = await getRnTotalBalance(); + + console.info('=== Legacy wallet setup complete ==='); + + // Output for iOS CI to capture + console.info(`\nexport RN_MNEMONIC="${mnemonic}"`); + console.info(`export RN_BALANCE="${balance}"\n`); + + return { mnemonic, balance }; +} + +// Amount constants for sweep scenario +const SWEEP_INITIAL_FUND_SATS = 200_000; +const SWEEP_SEND_TO_SELF_SATS = 50_000; + +/** + * Setup wallet with funds on legacy addresses (for sweep migration scenario) + * + * Flow: + * 1. Create wallet (default native segwit) + * 2. Fund wallet on native segwit (works with Blocktank) + * 3. Switch to legacy (p2pkh) address type + * 4. Send to self (to a new legacy address) + * 5. Send out from wallet + * + * Result: Wallet has funds on legacy address, migration will trigger sweep + */ +async function setupWalletWithLegacyFunds(): Promise<{ balance: number }> { + console.info('=== Setting up wallet with legacy funds (sweep scenario) ==='); + + // Install and create wallet + await installLegacyRnApp(); + await createLegacyRnWallet(); + + // 1. Fund wallet on native segwit (works with Blocktank) + console.info('โ†’ Step 1: Funding wallet on native segwit...'); + await fundRnWallet(SWEEP_INITIAL_FUND_SATS); + + // 2. Switch to legacy address type + console.info('โ†’ Step 2: Switching to legacy (p2pkh) address type...'); + await setRnAddressType('p2pkh'); + + // 3. Send to self (to new legacy address) + console.info('โ†’ Step 3: Sending to self (new legacy address)...'); + await sendRnToSelf(SWEEP_SEND_TO_SELF_SATS); + + // Get final balance before migration + const balance = await getRnTotalBalance(); + + console.info('=== Legacy funds setup complete ==='); + + return { balance }; +} + +/** + * Send BTC to wallet's own new address (self-transfer) + */ +async function sendRnToSelf(amountSats: number): Promise { + // Get a new receive address (will be legacy since we switched) + const receiveAddress = await getRnReceiveAddress(); + await sendRnOnchain(amountSats, { optionalAddress: receiveAddress }); } -async function installLegacyRnApp() { + +async function handleMigrationFlow({ withSweep = false }): Promise { + console.info('โ†’ Handling migration flow...'); + await expectText('MIGRATING', { strategy: 'contains' }); + await handleAndroidAlert(); + if (withSweep) { + await handleSweepFlow(); + } + await dismissBackupTimedSheet(); +} + +/** + * Handle the sweep flow during migration + * Sweep is triggered when wallet has funds on unsupported address types (legacy) + */ +async function handleSweepFlow(): Promise { + console.info('โ†’ Handling sweep flow...'); + + await elementById('SweepButton').waitForDisplayed(); + await sleep(1000); + + try { + await tap('SweepButton'); + console.info('โ†’ Clicked Sweep button by ID'); + } catch { + await tap('SweepButton'); + console.info('โ†’ Clicked Sweep button by ID again'); + } + + try { + await tap('SweepToWalletButton'); + console.info('โ†’ Clicked Sweep To Wallet button by ID'); + } catch { + await tap('SweepToWalletButton'); + console.info('โ†’ Clicked Sweep To Wallet button by ID again'); + } + + // Wait for sweep confirmation screen with swipe slider + await sleep(2000); + await elementById('GRAB').waitForDisplayed(); + await sleep(2000); + await dragOnElement('GRAB'); + await acknowledgeReceivedPayment(); + + // Mine blocks to confirm sweep transaction + await mineBlocks(1); + await sleep(2000); + + await doNavigationClose(); + + console.info('โ†’ Sweep flow complete'); +} + +/** + * Verify migration completed after sweep + * Balance should be preserved (minus fees from sweep transaction) + * @param expectedBalance - The balance from the RN app before migration + */ +async function verifyMigrationWithSweep(expectedBalance: number): Promise { + console.info('=== Verifying migration with sweep ==='); + + // After sweep, we should have balance (original minus fees from sweep tx) + await elementById('TotalBalance').waitForDisplayed({ timeout: 30_000 }); + + // Get actual balance + const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + const balanceText = await totalBalanceEl.getText(); + const actualBalance = parseInt(balanceText.replace(/\s/g, ''), 10); + console.info(`โ†’ Balance after sweep: ${actualBalance} sats (pre-sweep: ${expectedBalance})`); + + // Verify balance is close to expected (allow up to 10k sats for sweep fees) + const maxFeeLoss = 10_000; + if (actualBalance < expectedBalance - maxFeeLoss) { + throw new Error( + `Balance too low after sweep! Expected ~${expectedBalance} (minus fees), got ${actualBalance}` + ); + } + if (actualBalance > expectedBalance) { + throw new Error( + `Balance increased after sweep? Expected ~${expectedBalance}, got ${actualBalance}` + ); + } + console.info(`โ†’ Balance preserved after sweep (fee: ${expectedBalance - actualBalance} sats)`); + + // Verify wallet is functional by checking main screen elements + await elementById('ActivitySavings').waitForDisplayed(); + await elementById('Send').waitForDisplayed(); + await elementById('Receive').waitForDisplayed(); + + console.info('=== Migration with sweep verified ==='); +} + +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'); + // 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(); + + // 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'); +} + +/** + * Restore wallet in legacy RN app from mnemonic. + * Used on iOS where we can't create wallet due to Appium issues. + */ +async function restoreRnWallet( + mnemonic: string, + { passphrase }: { passphrase?: string } = {} +): Promise { + console.info('โ†’ Restoring wallet in legacy RN app...'); + + // Terms of service + await elementById('Continue').waitForDisplayed(); + await tap('Check1'); + await tap('Check2'); + await tap('Continue'); + + // Skip intro and go to restore + await tap('SkipIntro'); await tap('RestoreWallet'); await tap('MultipleDevices-button'); - await typeText('Word-0', seed); + // Enter seed + await typeText('Word-0', mnemonic); await sleep(1500); + + // Passphrase if provided + if (passphrase) { + await tap('AdvancedButton'); + await typeText('PassphraseInput', passphrase); + await confirmInputOnKeyboard(); + } + + // Restore wallet await tap('RestoreButton'); await waitForSetupWalletScreenFinish(); + // Wait for Get Started const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed( { timeout: 120000 }); + await getStarted.waitForDisplayed({ timeout: 120000 }); await tap('GetStartedButton'); await sleep(1000); + + // Dismiss wallet onboarding if shown + try { + await tap('WalletOnboardingClose'); + } catch { + // May not appear after restore + } + + console.info('โ†’ Legacy RN wallet restored'); +} + +/** + * Change Bitcoin address type in RN app settings + * @param addressType - 'p2pkh' (legacy), 'p2sh' (nested segwit), or 'p2wpkh' (native segwit) + */ +async function setRnAddressType(addressType: 'p2pkh' | 'p2sh' | 'p2wpkh'): Promise { + // Navigate to Settings > Advanced > Address Type + await tap('HeaderMenu'); + await sleep(500); + await elementById('DrawerSettings').waitForDisplayed(); + await tap('DrawerSettings'); + + await elementById('AdvancedSettings').waitForDisplayed(); + await tap('AdvancedSettings'); + + await elementById('AddressTypePreference').waitForDisplayed(); + await tap('AddressTypePreference'); + + // Select the address type + await elementById(addressType).waitForDisplayed(); + await tap(addressType); + + // Navigate back to main screen via drawer + await driver.back(); // AddressType -> Advanced + await sleep(300); + await driver.back(); // Advanced -> Settings + await sleep(300); + // Close settings by going to wallet via drawer + await tap('HeaderMenu'); + await sleep(500); + await elementById('DrawerWallet').waitForDisplayed(); + await tap('DrawerWallet'); + await sleep(500); + + console.info(`โ†’ Address type set to: ${addressType}`); +} + +// ============================================================================ +// 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}`); + // Use Android back button to dismiss RN sheet - more reliable than swipe + await dismissSheetRN(); + 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 dismissSheetRN(); +} + +/** + * 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, + { optionalAddress }: { optionalAddress?: string } = {} +): Promise { + const externalAddress = optionalAddress ?? (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 dismissSheetRN(); + console.info(`โ†’ Sent ${sats} sats`); +} + +/** + * Transfer savings to spending balance (create channel via Blocktank) + */ +async function transferToSpending(sats: number, existingBalance = 0): Promise { + // Navigate via ActivitySavings -> TransferToSpending + // ActivitySavings should be visible near the top of the wallet screen + try { + await elementById('ActivitySavings').waitForDisplayed({ timeout: 5000 }); + } catch { + console.info('โ†’ Scrolling to find ActivitySavings...'); + // Scroll down to reveal ActivitySavings if hidden + await swipeFullScreenRN('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); + await sleep(5000); + await handleAndroidAlert(); + + // 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 { + await elementById('TransferSuccess-button').waitForDisplayed(); + await sleep(1000); + break; + } catch { + console.info('โ†’ Transfer successful screen did not appear, waiting...'); + } + } + + await sleep(3000); + await tap('TransferSuccess-button'); + await electrumClient?.waitForSync(); + await sleep(3000); + await dismissSheetRN(); + const expectedBalance = (existingBalance + sats).toLocaleString('en').replace(/,/g, ' '); + await expectText(expectedBalance); + console.info(`โ†’ Created spending balance with ${sats} sats`); +} + +// @ts-expect-error - Kept for future use +async function createCJIT(sats: number): Promise { + await tap('Receive'); + await tap('ReceiveInstantlySwitch'); + + // Enter amount + await sleep(500); + const satsStr = String(sats); + for (const digit of satsStr) { + await tap(`N${digit}`); + } + await tap('ReceiveAmountContinue'); + await sleep(1000); + await tap('ReceiveConnectContinue'); + await sleep(2000); + const address = await getUriFromQRCode(); + await sleep(5000); + const tx = await payInvoice(address); + console.info(`โ†’ Created CJIT invoice and paid: ${tx}`); + await sleep(2000); + await swipeFullScreenRN('down'); + // 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 { + await elementById('TransferSuccess-button').waitForDisplayed(); + await sleep(1000); + break; + } catch { + console.info('โ†’ Transfer successful screen did not appear, waiting...'); + } + } +} + +/** + * Tag the latest (most recent) transaction in the activity list + */ +async function tagLatestTransaction(tag: string): Promise { + // Go to activity - scroll down to reveal activity section + await sleep(1000); + + // Try to find ActivityShort-1, scroll if needed + for (let attempt = 0; attempt < 3; attempt++) { + try { + await elementById('ActivityShort-1').waitForDisplayed({ timeout: 3000 }); + break; + } catch { + console.info(`โ†’ Scrolling to find latest transaction... (attempt ${attempt + 1})`); + await swipeFullScreenRN('up'); + await swipeFullScreenRN('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 to main screen + await driver.back(); + // Scroll back up to show balance area + await swipeFullScreenRN('down'); + await swipeFullScreenRN('down'); + 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}`); + // Close mnemonic sheet using back button - more reliable than swipe for RN + await dismissSheetRN(); + // 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(500); + + return seed; +} + +// ============================================================================ +// MIGRATION VERIFICATION +// ============================================================================ + +/** + * Verify migration was successful + * @param expectedBalance - The balance from the RN app before migration + */ +async function verifyMigration(expectedBalance: number): 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(); + const actualBalance = parseInt(balanceText.replace(/\s/g, ''), 10); + console.info(`โ†’ Total balance: ${actualBalance} sats (expected: ${expectedBalance})`); + + // Verify balance matches + if (actualBalance !== expectedBalance) { + throw new Error(`Balance mismatch! Expected ${expectedBalance}, got ${actualBalance}`); + } + 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 ==='); +} + +// ============================================================================ +// RN-SPECIFIC GESTURE HELPERS +// ============================================================================ + +/** + * Swipe gesture optimized for React Native apps. + * Uses slower gesture timing and longer pause to work better with RN's gesture system. + */ +async function swipeFullScreenRN(direction: 'up' | 'down') { + // RN apps need slower, more deliberate swipes with longer hold time + // This helps RN's gesture recognizers properly detect the swipe intent + const params = { + durationMs: 400, // Slower swipe for RN + pauseMs: 150, // Longer hold before moving + }; + + if (direction === 'up') { + // For scrolling content up (revealing bottom content) + await swipeFullScreen('up', { + upStartYPercent: 0.75, + upEndYPercent: 0.25, + ...params, + }); + } else { + // For dismissing sheets or scrolling content down + await swipeFullScreen('down', { + downStartYPercent: 0.35, + downEndYPercent: 0.85, + ...params, + }); + } + await sleep(300); // Extra settle time for RN animations +} + +/** + * Dismiss a bottom sheet in RN app. + * Uses Android back button which is more reliable than swipe gestures in RN. + */ +async function dismissSheetRN() { + await sleep(500); // Wait for sheet to fully render + if (driver.isAndroid) { + // Android back button is the most reliable way to dismiss sheets in RN + await driver.back(); + } else { + // iOS: use swipe down gesture + await swipeFullScreenRN('down'); + } + await sleep(500); // Wait for dismiss animation } 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',