From 9a904da625dc24c4579b988220375eb7747f9315 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 7 Jan 2026 13:26:17 +0100 Subject: [PATCH 01/20] formatting and small updates --- scripts/build-rn-android-apk.sh | 2 +- test/specs/send.e2e.ts | 4 +++- wdio.conf.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) 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/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index f7bbb27..e5efefc 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -356,7 +356,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; } diff --git a/wdio.conf.ts b/wdio.conf.ts index 7f5dad1..4aaedd9 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -65,7 +65,6 @@ 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, } : { From 41209d792913aee6c45d6e804567ce4b01c97518 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 7 Jan 2026 13:26:51 +0100 Subject: [PATCH 02/20] basic migration scenarios for android --- test/helpers/actions.ts | 8 ++- test/helpers/setup.ts | 6 +- test/specs/migration.e2e.ts | 125 +++++++++++++++++++++++++++++++++--- 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f1eb58b..0142fc4 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -522,14 +522,18 @@ export async function restoreWallet( { passphrase, expectQuickPayTimedSheet = false, - }: { passphrase?: string; expectQuickPayTimedSheet?: boolean } = {} + reinstall = true, + }: { passphrase?: string; expectQuickPayTimedSheet?: 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(); diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 911b7a0..8c86a3b 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -30,9 +30,7 @@ export function getRnAppPath(): string { const fallback = path.join(__dirname, '..', '..', 'aut', 'bitkit_rn_regtest.apk'); 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; } @@ -61,7 +59,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/migration.e2e.ts b/test/specs/migration.e2e.ts index 8d03450..2650aff 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,25 +1,64 @@ -import { elementById, restoreWallet, sleep, tap, typeText, waitForSetupWalletScreenFinish } from '../helpers/actions'; +import { + dismissBackupTimedSheet, + elementById, + elementByIdWithin, + expectText, + expectTextWithin, + 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'; const MIGRATION_MNEMONIC = process.env.MIGRATION_MNEMONIC ?? 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; describe('@migration - Legacy RN migration', () => { - ciIt('@migration_1 - Can restore legacy RN wallet from mnemonic', async () => { + ciIt('@migration_1 - Remove legacy RN app and install native app', async () => { await installLegacyRnApp(); await restoreLegacyRnWallet(MIGRATION_MNEMONIC); - // Restore into native app - // await installNativeApp(); - await restoreWallet(MIGRATION_MNEMONIC); + // Reinstall native app + console.info(`→ Reinstalling app from: ${getNativeAppPath()}`); + await driver.removeApp(getAppId()); + resetBootedIOSKeychain(); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // restore wallet and verify migration + await restoreWallet(MIGRATION_MNEMONIC, { reinstall: false }); + await verifyMigration(); + }); + + ciIt('@migration_2 - Install native app on top of legacy RN app', async () => { + await installLegacyRnApp(); + await restoreLegacyRnWallet(MIGRATION_MNEMONIC); + + // Install native app + console.info(`→ Installing app from: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // verify migration + await handleAndroidAlert(); + await expectText('Migration Complete'); + await dismissBackupTimedSheet(); + await verifyMigration(); }); }); -async function installNativeApp() { - await reinstallAppFromPath(getNativeAppPath()); -} async function installLegacyRnApp() { await reinstallAppFromPath(getRnAppPath()); } @@ -41,7 +80,73 @@ async function restoreLegacyRnWallet(seed: string) { await waitForSetupWalletScreenFinish(); const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed( { timeout: 120000 }); + await getStarted.waitForDisplayed({ timeout: 120000 }); await tap('GetStartedButton'); await sleep(1000); + await expectText(totalBalance); + await expectText(savingBalance); + await expectText(spendingBalance); +} + +const totalBalance = '141 321'; +const savingBalance = '91 766'; +const spendingBalance = '49 555'; + +async function verifyMigration() { + console.info('→ Verifying migrated wallet balances...'); + const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(totalBalanceEl).toHaveText(totalBalance); + await expectTextWithin('ActivitySpending', spendingBalance); + await expectTextWithin('ActivitySavings', savingBalance); + + console.info('→ Verify transaction details...'); + await swipeFullScreen('up'); + await swipeFullScreen('up'); + await tap('ActivityShowAll'); + + // All transactions + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '+'); + await expectTextWithin('Activity-3', '-'); + await expectTextWithin('Activity-4', '-'); + await expectTextWithin('Activity-5', '+'); + await expectTextWithin('Activity-6', '+'); + + // Sent, 2 transactions + await tap('Tab-sent'); + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '-'); + await expectTextWithin('Activity-3', '-'); + await elementById('Activity-4').waitForDisplayed({ reverse: true }); + + // Received, 2 transactions + await tap('Tab-received'); + await expectTextWithin('Activity-1', '+'); + await expectTextWithin('Activity-2', '+'); + await expectTextWithin('Activity-3', '+'); + await elementById('Activity-4').waitForDisplayed({ reverse: true }); + + // Other, 0 transactions + await tap('Tab-other'); + await elementById('Activity-1').waitForDisplayed(); + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + + // filter by receive tag + await tap('Tab-all'); + await tap('TagsPrompt'); + await sleep(500); + await tap('Tag-received '); + await expectTextWithin('Activity-1', '+'); + await expectTextWithin('Activity-2', '+'); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); + await tap('Tag-received -delete'); + + // filter by send tag + await tap('TagsPrompt'); + await sleep(500); + await tap('Tag-sent'); + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '-'); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); + await tap('Tag-sent-delete'); } From d5fb90ace220bfd85fe0f8219f86c5ef2222bce1 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 10:19:23 +0100 Subject: [PATCH 03/20] bitkit RN build script --- README.md | 6 +++ scripts/build-rn-ios-sim.sh | 73 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100755 scripts/build-rn-ios-sim.sh diff --git a/README.md b/README.md index 19acfcf..636fcbb 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 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" From f402f9df1db39ed649aa78abcdee90a495aaba54 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 10:19:58 +0100 Subject: [PATCH 04/20] adjustments for iOS --- test/helpers/actions.ts | 6 +++--- test/helpers/setup.ts | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 0142fc4..7e44467 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -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}"` ); } } @@ -567,7 +567,7 @@ export async function restoreWallet( // 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(); diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 8c86a3b..488f0d0 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -27,7 +27,8 @@ 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}`); @@ -36,7 +37,8 @@ export function getRnAppPath(): string { } 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( From 35fbe5cf30ff31ec0c4ee602f8fc8041b300df03 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 15:02:35 +0100 Subject: [PATCH 05/20] formatting --- test/helpers/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 7e44467..c1ab4ca 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -567,7 +567,7 @@ export async function restoreWallet( // 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); await handleAndroidAlert(); From cd425e48c1001a72bb421aa9ca6055c31c7c1629 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 18:02:10 +0100 Subject: [PATCH 06/20] adjust verify tx details --- test/specs/migration.e2e.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 2650aff..93c3b52 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,5 +1,6 @@ import { dismissBackupTimedSheet, + doNavigationClose, elementById, elementByIdWithin, expectText, @@ -112,21 +113,20 @@ async function verifyMigration() { await expectTextWithin('Activity-5', '+'); await expectTextWithin('Activity-6', '+'); - // Sent, 2 transactions + // Sent await tap('Tab-sent'); await expectTextWithin('Activity-1', '-'); await expectTextWithin('Activity-2', '-'); - await expectTextWithin('Activity-3', '-'); - await elementById('Activity-4').waitForDisplayed({ reverse: true }); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); - // Received, 2 transactions + // Received await tap('Tab-received'); await expectTextWithin('Activity-1', '+'); await expectTextWithin('Activity-2', '+'); await expectTextWithin('Activity-3', '+'); await elementById('Activity-4').waitForDisplayed({ reverse: true }); - // Other, 0 transactions + // Other await tap('Tab-other'); await elementById('Activity-1').waitForDisplayed(); await elementById('Activity-2').waitForDisplayed({ reverse: true }); @@ -149,4 +149,6 @@ async function verifyMigration() { await expectTextWithin('Activity-2', '-'); await elementById('Activity-3').waitForDisplayed({ reverse: true }); await tap('Tag-sent-delete'); + + await doNavigationClose(); } From f78caad1476abc75c00dadfeeda4bf13203915f6 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 22:20:59 +0100 Subject: [PATCH 07/20] attempt to use regtest and local backend --- test/helpers/actions.ts | 53 +++++++---- test/helpers/constants.ts | 7 +- test/helpers/regtest.ts | 181 ++++++++++++++++++++++++++++++++++++ test/specs/backup.e2e.ts | 7 +- test/specs/boost.e2e.ts | 15 ++- test/specs/lightning.e2e.ts | 11 +-- test/specs/lnurl.e2e.ts | 8 +- test/specs/migration.e2e.ts | 18 ++-- test/specs/onchain.e2e.ts | 18 ++-- test/specs/security.e2e.ts | 8 +- test/specs/send.e2e.ts | 13 ++- test/specs/transfer.e2e.ts | 14 ++- 12 files changed, 270 insertions(+), 83 deletions(-) create mode 100644 test/helpers/regtest.ts diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index c1ab4ca..cdc2f68 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); @@ -646,37 +646,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/regtest.ts b/test/helpers/regtest.ts new file mode 100644 index 0000000..79eae47 --- /dev/null +++ b/test/helpers/regtest.ts @@ -0,0 +1,181 @@ +/** + * 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'; + 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(); +} + +/** + * 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/specs/backup.e2e.ts b/test/specs/backup.e2e.ts index 3ea60d6..e2b3886 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,10 +17,11 @@ import { waitForBackup, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; +import { getBitcoinRpc } from '../helpers/regtest'; describe('@backup - Backup', () => { let electrum: Awaited> | undefined; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { // ensure we have at least 10 BTC on regtest @@ -57,7 +56,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..d797510 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,14 +17,14 @@ 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 { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@boost - Boost', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { let balance = await rpc.getBalance(); @@ -52,7 +49,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 +122,7 @@ describe('@boost - Boost', () => { await elementById('StatusBoosting').waitForDisplayed(); // mine new block - await mineBlocks(rpc, 1); + await mineBlocks(1); await doNavigationClose(); await sleep(500); @@ -142,7 +139,7 @@ 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(); @@ -231,7 +228,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..c9b88a1 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,10 +38,11 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; +import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@lightning - Lightning', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { let balance = await rpc.getBalance(); @@ -77,7 +76,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 +292,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..261a384 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 { getBitcoinRpc } from '../helpers/regtest'; function waitForEvent(lnurlServer: any, name: string): Promise { let timer: NodeJS.Timeout | undefined; @@ -57,7 +57,7 @@ function waitForEvent(lnurlServer: any, name: string): Promise { describe('@lnurl - LNURL', () => { let electrum: Awaited> | undefined; let lnurlServer: any; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { // Ensure we have at least 10 BTC on regtest @@ -105,7 +105,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(); diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 93c3b52..3d73679 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -26,13 +26,17 @@ const MIGRATION_MNEMONIC = process.env.MIGRATION_MNEMONIC ?? 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; -describe('@migration - Legacy RN migration', () => { +const totalBalance = '141 321'; +const savingBalance = '91 766'; +const spendingBalance = '49 555'; + +describe('@migration - Migration from legacy RN app to native app', () => { ciIt('@migration_1 - Remove legacy RN app and install native app', async () => { await installLegacyRnApp(); await restoreLegacyRnWallet(MIGRATION_MNEMONIC); // Reinstall native app - console.info(`→ Reinstalling app from: ${getNativeAppPath()}`); + console.info(`→ Remove and install app from: ${getNativeAppPath()}`); await driver.removeApp(getAppId()); resetBootedIOSKeychain(); await driver.installApp(getNativeAppPath()); @@ -84,15 +88,11 @@ async function restoreLegacyRnWallet(seed: string) { await getStarted.waitForDisplayed({ timeout: 120000 }); await tap('GetStartedButton'); await sleep(1000); - await expectText(totalBalance); - await expectText(savingBalance); - await expectText(spendingBalance); + await expectText(totalBalance, { strategy: 'contains' }); + await expectText(savingBalance, { strategy: 'contains' }); + await expectText(spendingBalance, { strategy: 'contains' }); } -const totalBalance = '141 321'; -const savingBalance = '91 766'; -const spendingBalance = '49 555'; - async function verifyMigration() { console.info('→ Verifying migrated wallet balances...'); const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); diff --git a/test/specs/onchain.e2e.ts b/test/specs/onchain.e2e.ts index 787d701..8858c2e 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,10 +24,11 @@ import { acknowledgeReceivedPayment, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; +import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@onchain - Onchain', () => { let electrum: Awaited> | undefined; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { // ensure we have at least 10 BTC on regtest @@ -57,7 +55,7 @@ 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(); @@ -72,7 +70,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]; @@ -127,7 +125,7 @@ describe('@onchain - Onchain', () => { await rpc.sendToAddress(address, '1'); await acknowledgeReceivedPayment(); - await mineBlocks(rpc, 1); + await mineBlocks(1); await electrum?.waitForSync(); await sleep(1000); // wait for the app to settle @@ -166,7 +164,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 +256,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'); @@ -294,7 +292,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..0b2ca05 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,14 +11,14 @@ 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 { getBitcoinRpc } from '../helpers/regtest'; describe('@security - Security And Privacy', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { let balance = await rpc.getBalance(); @@ -80,7 +78,7 @@ describe('@security - Security And Privacy', () => { await elementById('TotalBalance').waitForDisplayed(); // receive - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); // send, using PIN const coreAddress = await rpc.getNewAddress(); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index e5efefc..be129ea 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,10 +36,11 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; +import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@send - Send', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = new BitcoinJsonRpc(bitcoinURL); + const rpc = getBitcoinRpc(); before(async () => { let balance = await rpc.getBalance(); @@ -96,7 +95,7 @@ describe('@send - Send', () => { // Receive funds and check validation w/ balance await swipeFullScreen('down'); - await receiveOnchainFunds(rpc); + await receiveOnchainFunds(); await tap('Send'); await sleep(500); @@ -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); @@ -473,7 +472,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..e3e4dc4 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,14 +30,15 @@ import { waitForActiveChannel, waitForPeerConnection, } from '../helpers/lnd'; -import { bitcoinURL, lndConfig } from '../helpers/constants'; +import { lndConfig } from '../helpers/constants'; +import { 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); + const rpc = getBitcoinRpc(); before(async () => { let balance = await rpc.getBalance(); @@ -77,7 +75,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 +300,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 +360,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); From 37cd7afaa6725c4e8dc192c7331b8e6f223d6ae0 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 23:14:55 +0100 Subject: [PATCH 08/20] ensureLocalFunds and rpc initialization --- test/helpers/regtest.ts | 70 ++++++++++++++++++++++++++++++++++++- test/specs/backup.e2e.ts | 13 ++----- test/specs/boost.e2e.ts | 14 ++------ test/specs/lightning.e2e.ts | 15 +++----- test/specs/lnurl.e2e.ts | 17 ++++----- test/specs/onchain.e2e.ts | 21 ++++------- test/specs/security.e2e.ts | 14 ++------ test/specs/send.e2e.ts | 17 ++++----- test/specs/transfer.e2e.ts | 15 +++----- 9 files changed, 105 insertions(+), 91 deletions(-) diff --git a/test/helpers/regtest.ts b/test/helpers/regtest.ts index 79eae47..f9cb11e 100644 --- a/test/helpers/regtest.ts +++ b/test/helpers/regtest.ts @@ -13,7 +13,7 @@ import { bitcoinURL, blocktankURL } from './constants'; export type Backend = 'local' | 'regtest'; export function getBackend(): Backend { - const backend = process.env.BACKEND ?? 'local'; + 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'.`); } @@ -132,6 +132,74 @@ export function getBitcoinRpc(): BitcoinJsonRpc { 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. diff --git a/test/specs/backup.e2e.ts b/test/specs/backup.e2e.ts index e2b3886..2d5c9ca 100644 --- a/test/specs/backup.e2e.ts +++ b/test/specs/backup.e2e.ts @@ -17,22 +17,13 @@ import { waitForBackup, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc } from '../helpers/regtest'; +import { ensureLocalFunds } from '../helpers/regtest'; describe('@backup - Backup', () => { let electrum: Awaited> | undefined; - const rpc = getBitcoinRpc(); 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(); }); diff --git a/test/specs/boost.e2e.ts b/test/specs/boost.e2e.ts index d797510..7fb276e 100644 --- a/test/specs/boost.e2e.ts +++ b/test/specs/boost.e2e.ts @@ -20,21 +20,13 @@ import { import initElectrum from '../helpers/electrum'; import { reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; +import { ensureLocalFunds, getExternalAddress, mineBlocks } from '../helpers/regtest'; describe('@boost - Boost', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = getBitcoinRpc(); 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(); }); @@ -142,7 +134,7 @@ describe('@boost - Boost', () => { await receiveOnchainFunds(); // Send 10 000 - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); await enterAddress(coreAddress); await tap('N1'); await tap('N0'); diff --git a/test/specs/lightning.e2e.ts b/test/specs/lightning.e2e.ts index c9b88a1..3671f15 100644 --- a/test/specs/lightning.e2e.ts +++ b/test/specs/lightning.e2e.ts @@ -38,21 +38,16 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; +import { ensureLocalFunds, getBitcoinRpc, mineBlocks } from '../helpers/regtest'; describe('@lightning - Lightning', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = getBitcoinRpc(); + // 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(); }); diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index 261a384..1ba6586 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -33,7 +33,7 @@ import { waitForActiveChannel, setupLND, } from '../helpers/lnd'; -import { getBitcoinRpc } from '../helpers/regtest'; +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 = getBitcoinRpc(); + // 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 @@ -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/onchain.e2e.ts b/test/specs/onchain.e2e.ts index 8858c2e..2607081 100644 --- a/test/specs/onchain.e2e.ts +++ b/test/specs/onchain.e2e.ts @@ -24,22 +24,13 @@ import { acknowledgeReceivedPayment, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; +import { ensureLocalFunds, getExternalAddress, mineBlocks, sendToAddress } from '../helpers/regtest'; describe('@onchain - Onchain', () => { let electrum: Awaited> | undefined; - const rpc = getBitcoinRpc(); 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(); }); @@ -58,7 +49,7 @@ describe('@onchain - Onchain', () => { 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'); @@ -122,7 +113,7 @@ describe('@onchain - Onchain', () => { await tap('ShowQrReceive'); await swipeFullScreen('down'); - await rpc.sendToAddress(address, '1'); + await sendToAddress(address, '1'); await acknowledgeReceivedPayment(); await mineBlocks(1); @@ -141,7 +132,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 @@ -265,7 +256,7 @@ describe('@onchain - Onchain', () => { await tap('SendAmountWarning'); await doNavigationClose(); - const coreAddress = await rpc.getNewAddress(); + const coreAddress = await getExternalAddress(); console.info({ coreAddress }); await enterAddress(coreAddress); diff --git a/test/specs/security.e2e.ts b/test/specs/security.e2e.ts index 0b2ca05..e67e0d5 100644 --- a/test/specs/security.e2e.ts +++ b/test/specs/security.e2e.ts @@ -14,21 +14,13 @@ import { import initElectrum from '../helpers/electrum'; import { launchFreshApp, reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc } from '../helpers/regtest'; +import { ensureLocalFunds, getExternalAddress } from '../helpers/regtest'; describe('@security - Security And Privacy', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = getBitcoinRpc(); 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(); }); @@ -81,7 +73,7 @@ describe('@security - Security And Privacy', () => { 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 be129ea..76f76e8 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -36,21 +36,16 @@ import { checkChannelStatus, } from '../helpers/lnd'; import { ciIt } from '../helpers/suite'; -import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; +import { ensureLocalFunds, getBitcoinRpc, getExternalAddress, mineBlocks } from '../helpers/regtest'; describe('@send - Send', () => { let electrum: { waitForSync: any; stop: any }; - const rpc = getBitcoinRpc(); + // 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(); }); @@ -102,7 +97,7 @@ describe('@send - Send', () => { await tap('RecipientManual'); // check validation for address - const address2 = await rpc.getNewAddress(); + const address2 = await getExternalAddress(); try { await typeAddressAndVerifyContinue({ address: address2 }); } catch { diff --git a/test/specs/transfer.e2e.ts b/test/specs/transfer.e2e.ts index e3e4dc4..42b6081 100644 --- a/test/specs/transfer.e2e.ts +++ b/test/specs/transfer.e2e.ts @@ -31,24 +31,19 @@ import { waitForPeerConnection, } from '../helpers/lnd'; import { lndConfig } from '../helpers/constants'; -import { getBitcoinRpc, mineBlocks } from '../helpers/regtest'; +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 = getBitcoinRpc(); + // 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(); }); From fe638587e07ee25dc28e27789c9b8b3aa5ef06ff Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 23:22:16 +0100 Subject: [PATCH 09/20] electrum noop for regtest --- test/helpers/electrum.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) 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 { From 891b768da9f286aed1a86f2ff64d3163caff1230 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 7 Jan 2026 13:26:17 +0100 Subject: [PATCH 10/20] formatting and small updates --- scripts/build-rn-android-apk.sh | 2 +- test/specs/send.e2e.ts | 4 +++- wdio.conf.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) 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/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index f7bbb27..e5efefc 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -356,7 +356,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; } diff --git a/wdio.conf.ts b/wdio.conf.ts index 7f5dad1..4aaedd9 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -65,7 +65,6 @@ 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, } : { From 8d6c6802d3661cbfe3e4a7495505f25c4fe7afd7 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 7 Jan 2026 13:26:51 +0100 Subject: [PATCH 11/20] basic migration scenarios for android --- test/helpers/actions.ts | 8 ++- test/helpers/setup.ts | 6 +- test/specs/migration.e2e.ts | 125 +++++++++++++++++++++++++++++++++--- 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f1eb58b..0142fc4 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -522,14 +522,18 @@ export async function restoreWallet( { passphrase, expectQuickPayTimedSheet = false, - }: { passphrase?: string; expectQuickPayTimedSheet?: boolean } = {} + reinstall = true, + }: { passphrase?: string; expectQuickPayTimedSheet?: 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(); diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 911b7a0..8c86a3b 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -30,9 +30,7 @@ export function getRnAppPath(): string { const fallback = path.join(__dirname, '..', '..', 'aut', 'bitkit_rn_regtest.apk'); 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; } @@ -61,7 +59,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/migration.e2e.ts b/test/specs/migration.e2e.ts index 8d03450..2650aff 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,25 +1,64 @@ -import { elementById, restoreWallet, sleep, tap, typeText, waitForSetupWalletScreenFinish } from '../helpers/actions'; +import { + dismissBackupTimedSheet, + elementById, + elementByIdWithin, + expectText, + expectTextWithin, + 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'; const MIGRATION_MNEMONIC = process.env.MIGRATION_MNEMONIC ?? 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; describe('@migration - Legacy RN migration', () => { - ciIt('@migration_1 - Can restore legacy RN wallet from mnemonic', async () => { + ciIt('@migration_1 - Remove legacy RN app and install native app', async () => { await installLegacyRnApp(); await restoreLegacyRnWallet(MIGRATION_MNEMONIC); - // Restore into native app - // await installNativeApp(); - await restoreWallet(MIGRATION_MNEMONIC); + // Reinstall native app + console.info(`→ Reinstalling app from: ${getNativeAppPath()}`); + await driver.removeApp(getAppId()); + resetBootedIOSKeychain(); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // restore wallet and verify migration + await restoreWallet(MIGRATION_MNEMONIC, { reinstall: false }); + await verifyMigration(); + }); + + ciIt('@migration_2 - Install native app on top of legacy RN app', async () => { + await installLegacyRnApp(); + await restoreLegacyRnWallet(MIGRATION_MNEMONIC); + + // Install native app + console.info(`→ Installing app from: ${getNativeAppPath()}`); + await driver.installApp(getNativeAppPath()); + await driver.activateApp(getAppId()); + + // verify migration + await handleAndroidAlert(); + await expectText('Migration Complete'); + await dismissBackupTimedSheet(); + await verifyMigration(); }); }); -async function installNativeApp() { - await reinstallAppFromPath(getNativeAppPath()); -} async function installLegacyRnApp() { await reinstallAppFromPath(getRnAppPath()); } @@ -41,7 +80,73 @@ async function restoreLegacyRnWallet(seed: string) { await waitForSetupWalletScreenFinish(); const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed( { timeout: 120000 }); + await getStarted.waitForDisplayed({ timeout: 120000 }); await tap('GetStartedButton'); await sleep(1000); + await expectText(totalBalance); + await expectText(savingBalance); + await expectText(spendingBalance); +} + +const totalBalance = '141 321'; +const savingBalance = '91 766'; +const spendingBalance = '49 555'; + +async function verifyMigration() { + console.info('→ Verifying migrated wallet balances...'); + const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(totalBalanceEl).toHaveText(totalBalance); + await expectTextWithin('ActivitySpending', spendingBalance); + await expectTextWithin('ActivitySavings', savingBalance); + + console.info('→ Verify transaction details...'); + await swipeFullScreen('up'); + await swipeFullScreen('up'); + await tap('ActivityShowAll'); + + // All transactions + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '+'); + await expectTextWithin('Activity-3', '-'); + await expectTextWithin('Activity-4', '-'); + await expectTextWithin('Activity-5', '+'); + await expectTextWithin('Activity-6', '+'); + + // Sent, 2 transactions + await tap('Tab-sent'); + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '-'); + await expectTextWithin('Activity-3', '-'); + await elementById('Activity-4').waitForDisplayed({ reverse: true }); + + // Received, 2 transactions + await tap('Tab-received'); + await expectTextWithin('Activity-1', '+'); + await expectTextWithin('Activity-2', '+'); + await expectTextWithin('Activity-3', '+'); + await elementById('Activity-4').waitForDisplayed({ reverse: true }); + + // Other, 0 transactions + await tap('Tab-other'); + await elementById('Activity-1').waitForDisplayed(); + await elementById('Activity-2').waitForDisplayed({ reverse: true }); + + // filter by receive tag + await tap('Tab-all'); + await tap('TagsPrompt'); + await sleep(500); + await tap('Tag-received '); + await expectTextWithin('Activity-1', '+'); + await expectTextWithin('Activity-2', '+'); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); + await tap('Tag-received -delete'); + + // filter by send tag + await tap('TagsPrompt'); + await sleep(500); + await tap('Tag-sent'); + await expectTextWithin('Activity-1', '-'); + await expectTextWithin('Activity-2', '-'); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); + await tap('Tag-sent-delete'); } From 2efc644b784d72d5df6eacac6bef1c8672723270 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 10:19:23 +0100 Subject: [PATCH 12/20] bitkit RN build script --- README.md | 6 +++ scripts/build-rn-ios-sim.sh | 73 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100755 scripts/build-rn-ios-sim.sh diff --git a/README.md b/README.md index 19acfcf..636fcbb 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 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" From 226036e44434704e5092088623a6ee8eb84fe9d5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 10:19:58 +0100 Subject: [PATCH 13/20] adjustments for iOS --- test/helpers/actions.ts | 6 +++--- test/helpers/setup.ts | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 0142fc4..7e44467 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -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}"` ); } } @@ -567,7 +567,7 @@ export async function restoreWallet( // 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(); diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 8c86a3b..488f0d0 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -27,7 +27,8 @@ 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}`); @@ -36,7 +37,8 @@ export function getRnAppPath(): string { } 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( From 491bbe8c097cba06c8a0c4efc5ef4e78cb9b4623 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 15:02:35 +0100 Subject: [PATCH 14/20] formatting --- test/helpers/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 7e44467..c1ab4ca 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -567,7 +567,7 @@ export async function restoreWallet( // 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); await handleAndroidAlert(); From 743a34fcd5069c67ee950301181232b86f4bc710 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 8 Jan 2026 18:02:10 +0100 Subject: [PATCH 15/20] adjust verify tx details --- test/specs/migration.e2e.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 2650aff..93c3b52 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,5 +1,6 @@ import { dismissBackupTimedSheet, + doNavigationClose, elementById, elementByIdWithin, expectText, @@ -112,21 +113,20 @@ async function verifyMigration() { await expectTextWithin('Activity-5', '+'); await expectTextWithin('Activity-6', '+'); - // Sent, 2 transactions + // Sent await tap('Tab-sent'); await expectTextWithin('Activity-1', '-'); await expectTextWithin('Activity-2', '-'); - await expectTextWithin('Activity-3', '-'); - await elementById('Activity-4').waitForDisplayed({ reverse: true }); + await elementById('Activity-3').waitForDisplayed({ reverse: true }); - // Received, 2 transactions + // Received await tap('Tab-received'); await expectTextWithin('Activity-1', '+'); await expectTextWithin('Activity-2', '+'); await expectTextWithin('Activity-3', '+'); await elementById('Activity-4').waitForDisplayed({ reverse: true }); - // Other, 0 transactions + // Other await tap('Tab-other'); await elementById('Activity-1').waitForDisplayed(); await elementById('Activity-2').waitForDisplayed({ reverse: true }); @@ -149,4 +149,6 @@ async function verifyMigration() { await expectTextWithin('Activity-2', '-'); await elementById('Activity-3').waitForDisplayed({ reverse: true }); await tap('Tag-sent-delete'); + + await doNavigationClose(); } From 7f4518c161197a392424bce643a28d2833f9f5da Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 9 Jan 2026 09:39:26 +0100 Subject: [PATCH 16/20] stability --- test/specs/send.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index 76f76e8..ce2a1f8 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -392,6 +392,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' }); @@ -400,6 +401,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" From e9d5fc20599c18bb2dea55c3c3ffe8faf925fe6a Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 9 Jan 2026 13:55:37 +0100 Subject: [PATCH 17/20] basic migration android --- test/helpers/actions.ts | 7 +- test/specs/migration.e2e.ts | 391 ++++++++++++++++++++++++++++-------- 2 files changed, 312 insertions(+), 86 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index cdc2f68..2a991e4 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -522,8 +522,9 @@ export async function restoreWallet( { passphrase, expectQuickPayTimedSheet = false, + expectBackupSheet = false, reinstall = true, - }: { passphrase?: string; expectQuickPayTimedSheet?: boolean; reinstall?: boolean } = {} + }: { passphrase?: string; expectQuickPayTimedSheet?: boolean; expectBackupSheet?: boolean; reinstall?: boolean } = {} ) { console.info('→ Restoring wallet with seed:', seed); // Let cloud state flush - carried over from Detox @@ -572,6 +573,10 @@ export async function restoreWallet( await sleep(1000); await handleAndroidAlert(); + if (expectBackupSheet) { + await dismissBackupTimedSheet(); + } + if (expectQuickPayTimedSheet) { await dismissQuickPayIntro(); } diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 3d73679..c78a026 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,10 +1,13 @@ import { dismissBackupTimedSheet, doNavigationClose, + dragOnElement, elementById, elementByIdWithin, + enterAddress, expectText, - expectTextWithin, + getAccessibleText, + getReceiveAddress, handleAndroidAlert, restoreWallet, sleep, @@ -21,134 +24,352 @@ import { 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; -const totalBalance = '141 321'; -const savingBalance = '91 766'; -const spendingBalance = '49 555'; +// ============================================================================ +// 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 TRANSFER_TO_SPENDING_SATS = 100_000; // 100k for creating a channel + +// ============================================================================ +// TEST SUITE +// ============================================================================ describe('@migration - Migration from legacy RN app to native app', () => { - ciIt('@migration_1 - Remove legacy RN app and install native app', async () => { - await installLegacyRnApp(); - await restoreLegacyRnWallet(MIGRATION_MNEMONIC); + let mnemonic: string = ''; + + 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 app and install native app', async () => { + // Setup wallet in RN app + await setupLegacyWallet(); + + // Get mnemonic before uninstalling + mnemonic = await getRnMnemonic(); + console.info(`→ Got mnemonic: ${mnemonic.substring(0, 20)}...`); - // Reinstall native app - console.info(`→ Remove and install app from: ${getNativeAppPath()}`); + // 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 and verify migration - await restoreWallet(MIGRATION_MNEMONIC, { reinstall: false }); + // Restore wallet with mnemonic (uses custom flow to handle backup sheet) + await restoreWallet(mnemonic, { reinstall: false, expectBackupSheet: true }); + + // Verify migration await verifyMigration(); }); - ciIt('@migration_2 - Install native app on top of legacy RN app', async () => { - await installLegacyRnApp(); - await restoreLegacyRnWallet(MIGRATION_MNEMONIC); + // -------------------------------------------------------------------------- + // Migration Scenario 2: Install native on top of RN (upgrade) + // -------------------------------------------------------------------------- + ciIt('@migration_2 - Install native app on top of RN app', async () => { + // Setup wallet in RN app + await setupLegacyWallet(); - // Install native app - console.info(`→ Installing app from: ${getNativeAppPath()}`); + // 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()); - // verify migration + // Handle migration flow await handleAndroidAlert(); await expectText('Migration Complete'); await dismissBackupTimedSheet(); + + // Verify migration await verifyMigration(); }); }); -async function installLegacyRnApp() { +// ============================================================================ +// WALLET SETUP HELPERS (RN App) +// ============================================================================ + +/** + * Complete wallet setup in legacy RN app: + * 1. Create new wallet + * 2. Fund with on-chain tx + * + * TODO: Add these steps once basic flow works: + * 3. Send on-chain tx (with tag) + * 4. Transfer to spending balance (create channel) + */ +async function setupLegacyWallet(): Promise { + console.info('=== Setting up legacy RN wallet ==='); + + // Install and create wallet + await installLegacyRnApp(); + await createLegacyRnWallet(); + + // 1. Fund wallet (receive on-chain) + console.info('→ Step 1: Funding wallet on-chain...'); + await fundRnWallet(INITIAL_FUND_SATS); + + // TODO: Add more steps once basic migration works + // // 2. Send on-chain tx with tag + // console.info('→ Step 2: Sending on-chain tx...'); + // await sendRnOnchainWithTag(ONCHAIN_SEND_SATS, 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 ==='); +} + +async function installLegacyRnApp(): Promise { + console.info(`→ Installing legacy RN app from: ${getRnAppPath()}`); await reinstallAppFromPath(getRnAppPath()); } -async function restoreLegacyRnWallet(seed: string) { +async function createLegacyRnWallet(): Promise { + console.info('→ Creating new wallet in legacy RN app...'); 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'); + // Create new wallet + await tap('NewWallet'); await waitForSetupWalletScreenFinish(); - const getStarted = await elementById('GetStartedButton'); - await getStarted.waitForDisplayed({ timeout: 120000 }); - await tap('GetStartedButton'); - await sleep(1000); - await expectText(totalBalance, { strategy: 'contains' }); - await expectText(savingBalance, { strategy: 'contains' }); - await expectText(spendingBalance, { strategy: 'contains' }); + // 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'); } -async function verifyMigration() { - console.info('→ Verifying migrated wallet balances...'); - const totalBalanceEl = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); - await expect(totalBalanceEl).toHaveText(totalBalance); - await expectTextWithin('ActivitySpending', spendingBalance); - await expectTextWithin('ActivitySavings', savingBalance); +// ============================================================================ +// 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 + */ +async function sendRnOnchainWithTag(sats: number, tag: string): Promise { + const externalAddress = await getExternalAddress(); + + // Use existing helper for address entry (handles camera permission) + await enterAddress(externalAddress); + + // Enter amount + const satsStr = String(sats); + for (const digit of satsStr) { + await tap(`N${digit}`); + } + await tap('ContinueAmount'); - console.info('→ Verify transaction details...'); + // Add tag before sending + await elementById('TagsAddSend').waitForDisplayed(); + await tap('TagsAddSend'); + await typeText('TagInputSend', tag); + await tap('TagsAddSend'); // confirm tag + + // Send + await dragOnElement('GRAB', 'right', 0.95); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + + // Mine and sync + await mineBlocks(1); + await electrumClient?.waitForSync(); + await sleep(2000); + console.info(`→ Sent ${sats} sats with tag "${tag}"`); +} + +/** + * Transfer savings to spending balance (create channel via Blocktank) + */ +async function transferToSpending(sats: number): Promise { + // Navigate to transfer + await tap('Suggestion-lightning'); + await elementById('TransferIntro-button').waitForDisplayed(); + await tap('TransferIntro-button'); + await tap('FundTransfer'); + await tap('SpendingIntro-button'); + await sleep(2000); // let animation finish + + // Enter amount + const satsStr = String(sats); + for (const digit of satsStr) { + await tap(`N${digit}`); + } + await tap('SpendingAmountContinue'); + + // Confirm with default settings + await tap('SpendingConfirmDefault'); + await dragOnElement('GRAB', 'right', 0.95); + + // Wait for channel to be created + await elementById('TransferSuccess').waitForDisplayed({ timeout: 120000 }); + await tap('TransferSuccess'); + + // Mine blocks to confirm channel + await mineBlocks(6); + await electrumClient?.waitForSync(); + await sleep(5000); + 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 swipeFullScreen('up'); await swipeFullScreen('up'); await tap('ActivityShowAll'); - // All transactions - await expectTextWithin('Activity-1', '-'); - await expectTextWithin('Activity-2', '+'); - await expectTextWithin('Activity-3', '-'); - await expectTextWithin('Activity-4', '-'); - await expectTextWithin('Activity-5', '+'); - await expectTextWithin('Activity-6', '+'); - - // Sent - await tap('Tab-sent'); - await expectTextWithin('Activity-1', '-'); - await expectTextWithin('Activity-2', '-'); - await elementById('Activity-3').waitForDisplayed({ reverse: true }); - - // Received - await tap('Tab-received'); - await expectTextWithin('Activity-1', '+'); - await expectTextWithin('Activity-2', '+'); - await expectTextWithin('Activity-3', '+'); - await elementById('Activity-4').waitForDisplayed({ reverse: true }); - - // Other - await tap('Tab-other'); - await elementById('Activity-1').waitForDisplayed(); - await elementById('Activity-2').waitForDisplayed({ reverse: true }); + // Tap latest transaction + await tap('Activity-1'); - // filter by receive tag - await tap('Tab-all'); - await tap('TagsPrompt'); - await sleep(500); - await tap('Tag-received '); - await expectTextWithin('Activity-1', '+'); - await expectTextWithin('Activity-2', '+'); - await elementById('Activity-3').waitForDisplayed({ reverse: true }); - await tap('Tag-received -delete'); - - // filter by send tag - await tap('TagsPrompt'); + // Add tag + await elementById('ActivityTags').waitForDisplayed(); + await tap('ActivityTags'); + await typeText('TagInput', tag); + await tap('ActivityTagsSubmit'); + + // Go back + await doNavigationClose(); + await doNavigationClose(); + console.info(`→ Tagged latest transaction with "${tag}"`); +} + +/** + * Get mnemonic from RN wallet settings + */ +async function getRnMnemonic(): Promise { + // Navigate to backup settings + await tap('HeaderMenu'); + await sleep(500); // Wait for drawer to open + await elementById('DrawerSettings').waitForDisplayed(); + 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.split(' ').slice(0, 3).join(' ')}...`); + + // 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); - await tap('Tag-sent'); - await expectTextWithin('Activity-1', '-'); - await expectTextWithin('Activity-2', '-'); - await elementById('Activity-3').waitForDisplayed({ reverse: true }); - await tap('Tag-sent-delete'); + + 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'); + + // Verify we have at least one transaction (the receive) + await elementById('Activity-1').waitForDisplayed(); + console.info('→ Transaction history migrated successfully'); await doNavigationClose(); + + console.info('=== Migration verified successfully ==='); } From 5cdbe5cbacb0e486878470ebd4a2b903694ced44 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 12 Jan 2026 11:57:41 +0100 Subject: [PATCH 18/20] additional scenarios with passphrase --- test/specs/migration.e2e.ts | 92 ++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index c78a026..5c16ecf 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -1,4 +1,5 @@ import { + confirmInputOnKeyboard, dismissBackupTimedSheet, doNavigationClose, dragOnElement, @@ -43,12 +44,14 @@ 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', () => { - let mnemonic: string = ''; before(async () => { await ensureLocalFunds(); @@ -67,9 +70,8 @@ describe('@migration - Migration from legacy RN app to native app', () => { await setupLegacyWallet(); // Get mnemonic before uninstalling - mnemonic = await getRnMnemonic(); - console.info(`→ Got mnemonic: ${mnemonic.substring(0, 20)}...`); - + const mnemonic = await getRnMnemonic(); + await sleep(1000); // Uninstall RN app console.info('→ Removing legacy RN app...'); await driver.removeApp(getAppId()); @@ -107,6 +109,59 @@ describe('@migration - Migration from legacy RN app to native app', () => { // Verify migration await verifyMigration(); }); + + // -------------------------------------------------------------------------- + // Migration Scenario 3: Uninstall RN, install Native, restore with passphrase + // -------------------------------------------------------------------------- + ciIt('@migration_3 - Uninstall RN app and install native app (with passphrase)', async () => { + // Setup wallet in RN app WITH passphrase + await setupLegacyWallet({ passphrase: TEST_PASSPHRASE }); + + // Get mnemonic before uninstalling + const mnemonic = await getRnMnemonic(); + await sleep(10000); + + // 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 app on top of RN app (with passphrase)', 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 expectText('Migration Complete'); + await dismissBackupTimedSheet(); + + // Verify migration + await verifyMigration(); + }); }); // ============================================================================ @@ -115,19 +170,20 @@ describe('@migration - Migration from legacy RN app to native app', () => { /** * Complete wallet setup in legacy RN app: - * 1. Create new wallet + * 1. Create new wallet (optionally with passphrase) * 2. Fund with on-chain tx * * TODO: Add these steps once basic flow works: * 3. Send on-chain tx (with tag) * 4. Transfer to spending balance (create channel) */ -async function setupLegacyWallet(): Promise { - console.info('=== Setting up legacy RN wallet ==='); +async function setupLegacyWallet(options: { passphrase?: string } = {}): Promise { + const { passphrase } = options; + console.info(`=== Setting up legacy RN wallet${passphrase ? ' (with passphrase)' : ''} ===`); // Install and create wallet await installLegacyRnApp(); - await createLegacyRnWallet(); + await createLegacyRnWallet({ passphrase }); // 1. Fund wallet (receive on-chain) console.info('→ Step 1: Funding wallet on-chain...'); @@ -150,16 +206,27 @@ async function installLegacyRnApp(): Promise { await reinstallAppFromPath(getRnAppPath()); } -async function createLegacyRnWallet(): Promise { - console.info('→ Creating new wallet in legacy RN app...'); +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 tap('NewWallet'); + } await waitForSetupWalletScreenFinish(); // Wait for wallet to be created @@ -283,6 +350,7 @@ async function transferToSpending(sats: number): Promise { */ async function tagLatestTransaction(tag: string): Promise { // Go to activity + await sleep(1000); await swipeFullScreen('up'); await swipeFullScreen('up'); await tap('ActivityShowAll'); @@ -324,7 +392,7 @@ async function getRnMnemonic(): Promise { const seed = await getAccessibleText(seedElement); if (!seed) throw new Error('Could not read seed from "SeedContaider"'); - console.info(`→ RN mnemonic retrieved: ${seed.split(' ').slice(0, 3).join(' ')}...`); + console.info(`→ RN mnemonic retrieved: ${seed}...`); // Navigate back to main screen using Android back button // ShowMnemonic -> BackupSettings -> Settings -> Main From 17c5d6b703956df5174a2851627dd3e60611bc6e Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 12 Jan 2026 12:11:41 +0100 Subject: [PATCH 19/20] remove Migration Complete toast check --- test/specs/migration.e2e.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 5c16ecf..4ccd299 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -103,7 +103,6 @@ describe('@migration - Migration from legacy RN app to native app', () => { // Handle migration flow await handleAndroidAlert(); - await expectText('Migration Complete'); await dismissBackupTimedSheet(); // Verify migration @@ -156,7 +155,6 @@ describe('@migration - Migration from legacy RN app to native app', () => { // Handle migration flow await handleAndroidAlert(); - await expectText('Migration Complete'); await dismissBackupTimedSheet(); // Verify migration From 29260f3fcc50266cc6d61d8a6748baa892e525bb Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 12 Jan 2026 13:20:56 +0100 Subject: [PATCH 20/20] update titles --- test/specs/migration.e2e.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 4ccd299..906ca2b 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -65,7 +65,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { // -------------------------------------------------------------------------- // Migration Scenario 1: Uninstall RN, install Native, restore mnemonic // -------------------------------------------------------------------------- - ciIt('@migration_1 - Uninstall RN app and install native app', async () => { + ciIt('@migration_1 - Uninstall RN, install Native, restore mnemonic', async () => { // Setup wallet in RN app await setupLegacyWallet(); @@ -92,7 +92,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { // -------------------------------------------------------------------------- // Migration Scenario 2: Install native on top of RN (upgrade) // -------------------------------------------------------------------------- - ciIt('@migration_2 - Install native app on top of RN app', async () => { + ciIt('@migration_2 - Install native on top of RN (upgrade)', async () => { // Setup wallet in RN app await setupLegacyWallet(); @@ -112,7 +112,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { // -------------------------------------------------------------------------- // Migration Scenario 3: Uninstall RN, install Native, restore with passphrase // -------------------------------------------------------------------------- - ciIt('@migration_3 - Uninstall RN app and install native app (with passphrase)', async () => { + ciIt('@migration_3 - Uninstall RN, install Native, restore with passphrase', async () => { // Setup wallet in RN app WITH passphrase await setupLegacyWallet({ passphrase: TEST_PASSPHRASE }); @@ -144,7 +144,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { // -------------------------------------------------------------------------- // Migration Scenario 4: Install native on top of RN with passphrase (upgrade) // -------------------------------------------------------------------------- - ciIt('@migration_4 - Install native app on top of RN app (with passphrase)', async () => { + 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 });