diff --git a/README.md b/README.md index c2d5947..9b60d51 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ npm install ``` artifacts/ # screenshots and (optionally) videos of failed tests -aut/ # Place your .apk / .ipa files here +aut/ # Place your .apk / .app files here (default: bitkit_e2e.apk, Bitkit.app) docker/ # docker compose regtest based backend for Bitkit wallet test/ ├── specs/ # Test suites (e.g., onboarding.e2e.ts) @@ -95,6 +95,8 @@ BACKEND=regtest ./scripts/build-ios-sim.sh > ⚠️ **The `BACKEND` must match how the app was built.** If the app connects to remote electrum, use `BACKEND=regtest`. If it connects to localhost, use `BACKEND=local`. +**App override:** By default tests use `aut/bitkit_e2e.apk` (Android) and `aut/Bitkit.app` (iOS). Set `AUT_FILENAME` to use a different file in `aut/` (e.g. `AUT_FILENAME=bitkit_rn_regtest.apk`) + ```bash # Run all tests on Android (local backend - default) npm run e2e:android @@ -122,6 +124,12 @@ To run a **specific test case**: npm run e2e:android -- --mochaOpts.grep "Can pass onboarding correctly" ``` +To run against a **different app** in `aut/`: + +```bash +AUT_FILENAME=bitkit_rn_regtest.apk npm run e2e:android +``` + --- ### 🏷️ Tags diff --git a/docker/bitcoin-cli b/docker/bitcoin-cli index c68fa49..6c86d0c 100755 --- a/docker/bitcoin-cli +++ b/docker/bitcoin-cli @@ -3,13 +3,15 @@ set -euo pipefail CLI_NAME="$(basename $0)" -CLI_DIR="$(dirname "$(readlink -f "$0")")" -CONTAINER="bitcoind" +BITCOIN_CONTAINER="bitcoind" +LND_CONTAINER="lnd" +LND_DIR="/home/lnd/.lnd" RPC_USER=polaruser RPC_PASS=polarpass RPC_PORT=43782 -BASE_COMMAND=(docker compose exec $CONTAINER bitcoin-cli -rpcport=$RPC_PORT -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS) +BASE_COMMAND=(docker compose exec $BITCOIN_CONTAINER bitcoin-cli -rpcport=$RPC_PORT -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS) +LNCLI_CMD=(docker compose exec -T $LND_CONTAINER lncli --lnddir "$LND_DIR" --network regtest) DEFAULT_AMOUNT=0.001 @@ -21,9 +23,14 @@ Flags: -h, --help Show this help message Commands: fund Fund the wallet - mine [--auto] Generate a number of blocks - send
Send to address or BIP21 URI + mine [count] [--auto] Generate blocks (default: 1) + send [amount] [address] [-m N] Send to address and optionally mine N blocks (default: 1) getInvoice Get a new BIP21 URI with a bech32 address + LND: + getinfo Show LND node info (for connectivity debugging) + holdinvoice [amount] [-m memo] Create a hold invoice + settleinvoice Reveal a preimage and use it to settle the corresponding invoice + cancelinvoice Cancels a currently open invoice EOF } @@ -43,14 +50,7 @@ fi if [[ "$command" = "mine" ]]; then shift - if [ -z ${1+x} ]; then - echo "Specify the number of blocks to generate." - echo "Usage: \`$CLI_NAME $command \`" - exit 1 - fi - POSITIONAL_ARGS=() - auto=false while [[ $# -gt 0 ]]; do @@ -72,7 +72,9 @@ if [[ "$command" = "mine" ]]; then set -- "${POSITIONAL_ARGS[@]}" - # default to 5 seconds + # Default to 1 block if not specified + count=${1:-1} + # Default to 5 seconds interval for auto mode interval=${2:-5} if $auto; then @@ -83,7 +85,7 @@ if [[ "$command" = "mine" ]]; then sleep $interval done else - "${BASE_COMMAND[@]}" -generate "$@" + "${BASE_COMMAND[@]}" -generate "$count" fi exit @@ -93,31 +95,59 @@ fi if [[ "$command" = "send" ]]; then shift + mine_blocks=0 + POSITIONAL_ARGS=() + + while [[ $# -gt 0 ]]; do + case $1 in + -m|--mine) + # Check if next arg is a number + if [[ "${2:-}" =~ ^[0-9]+$ ]]; then + mine_blocks="$2" + shift 2 + else + mine_blocks=1 + shift + fi + ;; + *) + POSITIONAL_ARGS+=("$1") + shift + ;; + esac + done + + set -- "${POSITIONAL_ARGS[@]}" + if [ -z ${1+x} ]; then read -p "Enter a BIP21 URI or address: " uri echo - else - uri="$1" - fi - - if [ -z ${2+x} ]; then amount=$DEFAULT_AMOUNT else - amount="$2" + amount="$1" + if [ -z ${2+x} ]; then + read -p "Enter a BIP21 URI or address: " uri + echo + else + uri="$2" + fi fi - protocol=$(echo "${uri%%:*}") + protocol="${uri%%:*}" if [[ "$protocol" == "bitcoin" ]]; then - # BIP21 URI - # Remove the protocol - url_no_protocol=$(echo "${uri/$protocol/}" | cut -d":" -f2-) - - address=$(echo $url_no_protocol | grep "?" | cut -d"/" -f1 | rev | cut -d"?" -f2- | rev || echo $url_no_protocol) - uri_amount=$(echo $url_no_protocol | cut -d'?' -f 2 | cut -d'=' -f 2 | cut -d'&' -f 1) - - if echo "$uri_amount" | grep -qE '^[0-9]*\.?[0-9]+$'; then - amount=$uri_amount + # BIP21 URI: bitcoin:address?amount=X&message=Y&... + url_no_protocol="${uri#bitcoin:}" + + # Extract address (everything before ?) + address="${url_no_protocol%%\?*}" + + # Extract amount if present (look for amount= parameter specifically) + if [[ "$url_no_protocol" == *"?"* ]]; then + query_string="${url_no_protocol#*\?}" + if [[ "$query_string" =~ (^|&)amount=([0-9]*\.?[0-9]+) ]]; then + amount="${BASH_REMATCH[2]}" + fi fi else address=$uri @@ -128,6 +158,12 @@ if [[ "$command" = "send" ]]; then echo "Sent $amount BTC to $address" echo "Transaction ID: $tx_id" + # Mine blocks if requested + if [[ $mine_blocks -gt 0 ]]; then + "${BASE_COMMAND[@]}" -generate "$mine_blocks" + echo "Mined $mine_blocks block(s)" + fi + exit fi @@ -154,6 +190,100 @@ if [[ "$command" = "getInvoice" ]]; then exit fi +# Show LND node info (LND) +if [[ "$command" = "getinfo" ]]; then + "${LNCLI_CMD[@]}" getinfo + exit +fi + +# Create a hold invoice (LND) +if [[ "$command" = "holdinvoice" ]]; then + shift + + amount="" + memo="" + + while [[ $# -gt 0 ]]; do + case $1 in + -m|--memo) + memo="${2:-}" + shift 2 + ;; + -*) + echo "Unknown option $1" + exit 1 + ;; + *) + if [[ "$1" =~ ^[0-9]+$ ]]; then + amount="$1" + fi + shift + ;; + esac + done + + preimage=$(openssl rand -hex 32) + hash=$(echo -n "$preimage" | xxd -r -p | openssl dgst -sha256 | awk '{print $2}') + + if [[ -n "$amount" ]]; then + result=$("${LNCLI_CMD[@]}" addholdinvoice --amt "$amount" --memo "$memo" "$hash" 2>&1) || true + else + result=$("${LNCLI_CMD[@]}" addholdinvoice --memo "$memo" "$hash" 2>&1) || true + fi + + payment_request=$(echo "$result" | jq -r '.payment_request // empty' 2>/dev/null) || payment_request="" + if [ -z "$payment_request" ]; then + payment_request=$(echo "$result" | sed -n 's/.*"payment_request"[^:]*:[^"]*"\([^"]*\)".*/\1/p') || payment_request="" + fi + + if [ -n "$payment_request" ]; then + echo "Hold invoice created:" + echo "" + echo "$payment_request" + echo "" + echo "Hash: $hash" + echo "Preimage: $preimage" + echo "" + + if command -v pbcopy &>/dev/null; then + echo "$payment_request" | pbcopy + echo "Invoice copied to clipboard." + fi + else + echo "${result:-LND command produced no output}" + exit 1 + fi + + exit +fi + +# Settle a hold invoice (LND) +if [[ "$command" = "settleinvoice" ]]; then + shift + + if [ -z ${1+x} ]; then + echo "Usage: $CLI_NAME settleinvoice " + exit 1 + fi + + preimage="$1" + "${LNCLI_CMD[@]}" settleinvoice "$preimage" + exit +fi + +# Cancel a hold invoice (LND) +if [[ "$command" = "cancelinvoice" ]]; then + shift + + if [ -z ${1+x} ]; then + echo "Usage: $CLI_NAME cancelinvoice " + exit 1 + fi + + "${LNCLI_CMD[@]}" cancelinvoice "$1" + exit +fi + # Show usage information for this CLI if [[ "$command" = "--help" ]] || [[ "$command" = "-h" ]]; then show_help diff --git a/docs/mainnet-nightly.md b/docs/mainnet-nightly.md index d57503d..4ae6a9c 100644 --- a/docs/mainnet-nightly.md +++ b/docs/mainnet-nightly.md @@ -31,7 +31,7 @@ The private companion repository (`bitkit-nightly`) is responsible for running t To execute native E2E tests from an external orchestrator: - set platform/backend env vars expected by WDIO and helpers -- provide app artifact at `aut/bitkit_e2e.apk` (or `NATIVE_APK_PATH`) +- provide app artifact in `aut/` — default `bitkit_e2e.apk` (Android) / `Bitkit.app` (iOS). Override with `AUT_FILENAME` (e.g. `bitkit_rn_regtest.apk`) - provide all secrets required by the selected tag(s) - pass grep/tag filters via CLI args, not by editing spec files diff --git a/scripts/build-rn-android-apk.sh b/scripts/build-rn-android-apk.sh index 73ca65e..88bd5f5 100755 --- a/scripts/build-rn-android-apk.sh +++ b/scripts/build-rn-android-apk.sh @@ -29,7 +29,7 @@ if [[ -z "${ENV_FILE:-}" ]]; then if [[ "$BACKEND" == "regtest" ]]; then ENV_FILE=".env.development.template" else - ENV_FILE=".env.development" + ENV_FILE=".env.test.template" fi fi diff --git a/scripts/build-rn-ios-sim.sh b/scripts/build-rn-ios-sim.sh index 24d7585..328d3c6 100755 --- a/scripts/build-rn-ios-sim.sh +++ b/scripts/build-rn-ios-sim.sh @@ -29,7 +29,7 @@ if [[ -z "${ENV_FILE:-}" ]]; then if [[ "$BACKEND" == "regtest" ]]; then ENV_FILE=".env.development.template" else - ENV_FILE=".env.development" + ENV_FILE=".env.test.template" fi fi diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 8057285..27d3b74 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -134,14 +134,14 @@ export async function elementsByText(text: string, timeout = 8000): Promise { + const text = await getTextUnder(containerId, index); + const digits = text.replace(/[^\d]/g, ''); + if (!digits) { + throw new Error(`No numeric value found under container "${containerId}"`); + } + return Number(digits); +} + +export async function getSavingsBalance(): Promise { + return await getAmountUnder('ActivitySavings'); +} + +export async function getSpendingBalance(): Promise { + return await getAmountUnder('ActivitySpending'); +} + +export async function getTotalBalance(): Promise { + const totalText = await (await elementByIdWithin('TotalBalance-primary', 'MoneyText')).getText(); + const digits = totalText.replace(/[^\d]/g, ''); + if (!digits) { + throw new Error('No numeric value found in TotalBalance-primary/MoneyText'); + } + return Number(digits); +} + +export type BalanceCondition = 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; + +function checkBalanceCondition(value: number, expected: number, condition: BalanceCondition): boolean { + switch (condition) { + case 'eq': + return value === expected; + case 'gt': + return value > expected; + case 'gte': + return value >= expected; + case 'lt': + return value < expected; + case 'lte': + return value <= expected; + } +} + +async function expectBalanceWithWait( + getter: () => Promise, + name: string, + expected: number, + { + condition = 'eq', + timeout = 90_000, + interval = 2_000, + }: { + condition?: BalanceCondition; + timeout?: number; + interval?: number; + } = {} +): Promise { + let lastValue = -1; + await browser.waitUntil( + async () => { + const value = await getter(); + lastValue = value; + return checkBalanceCondition(value, expected, condition); + }, + { + timeout, + interval, + timeoutMsg: `Timed out after ${timeout}ms waiting for ${name} balance ${condition} ${expected}, last value: ${lastValue}`, + } + ); + return lastValue; +} + +export async function expectSavingsBalance( + expected: number, + options: { condition?: BalanceCondition; timeout?: number; interval?: number } = {} +): Promise { + return expectBalanceWithWait(getSavingsBalance, 'savings', expected, options); +} + +export async function expectSpendingBalance( + expected: number, + options: { condition?: BalanceCondition; timeout?: number; interval?: number } = {} +): Promise { + return expectBalanceWithWait(getSpendingBalance, 'spending', expected, options); +} + +export async function expectTotalBalance( + expected: number, + options: { condition?: BalanceCondition; timeout?: number; interval?: number } = {} +): Promise { + return expectBalanceWithWait(getTotalBalance, 'total', expected, options); +} + export async function tap(testId: string) { const el = await elementById(testId); await el.waitForDisplayed(); @@ -637,6 +735,254 @@ export async function restoreWallet( } type addressType = 'bitcoin' | 'lightning'; +export type addressTypePreference = 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' | 'p2tr'; + +export async function waitForAnyText(texts: string[], timeout: number) { + await browser.waitUntil( + async () => { + for (const text of texts) { + if (await elementByText(text, 'contains').isDisplayed().catch(() => false)) { + return true; + } + } + return false; + }, + { + timeout, + interval: 250, + timeoutMsg: `Timed out waiting for one of texts: ${texts.join(', ')}`, + } + ); +} + +export async function waitForTextToDisappear(texts: string[], timeout: number) { + await browser.waitUntil( + async () => { + for (const text of texts) { + if (await elementByText(text, 'contains').isDisplayed().catch(() => false)) { + return false; + } + } + return true; + }, + { + timeout, + interval: 250, + timeoutMsg: `Timed out waiting for texts to disappear: ${texts.join(', ')}`, + } + ); +} + +async function assertAddressTypeSwitchFeedback() { + // await waitForToast('AddressTypeApplyingToast', { dismiss: false }); + await waitForToast('AddressTypeSettingsUpdatedToast'); +} + +export async function switchPrimaryAddressType(nextType: addressTypePreference) { + await tap('HeaderMenu'); + await tap('DrawerSettings'); + await tap('AdvancedSettings'); + await tap('AddressTypePreference'); + await tap(nextType); + await assertAddressTypeSwitchFeedback(); + await doNavigationClose().catch(async () => { + await driver.back(); + await sleep(500); + await doNavigationClose(); + }); + await elementById('Receive').waitForDisplayed({ timeout: 60_000 }); +} + +export function assertAddressMatchesType(address: string, selectedType: addressTypePreference) { + const lower = address.toLowerCase(); + const matches = (() => { + switch (selectedType) { + case 'p2pkh': + return lower.startsWith('m') || lower.startsWith('n'); + case 'p2sh-p2wpkh': + return lower.startsWith('2'); + case 'p2wpkh': + return lower.startsWith('bcrt1q'); + case 'p2tr': + return lower.startsWith('bcrt1p'); + default: + return false; + } + })(); + + if (!matches) { + throw new Error(`Address ${address} does not match selected address type ${selectedType}`); + } +} + +export async function switchAndFundEachAddressType({ + addressTypes = ['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2tr'], + satsPerAddressType = 100_000, + waitForSync, + dismissBackupAfterFirstFunding = true, +}: { + addressTypes?: addressTypePreference[]; + satsPerAddressType?: number; + waitForSync?: () => Promise; + dismissBackupAfterFirstFunding?: boolean; +} = {}): Promise<{ + fundedAddresses: { type: addressTypePreference; address: string }[]; + totalFundedSats: number; +}> { + const fundedAddresses: { type: addressTypePreference; address: string }[] = []; + + for (let i = 0; i < addressTypes.length; i++) { + const addressType = addressTypes[i]; + await switchPrimaryAddressType(addressType); + const address = await getReceiveAddress(); + assertAddressMatchesType(address, addressType); + await swipeFullScreen('down'); + + await deposit(address, satsPerAddressType); + let didAcknowledgeReceivedPayment = false; + try { + await acknowledgeReceivedPayment(); + didAcknowledgeReceivedPayment = true; + } catch { + // may already be auto-confirmed on some app versions + } + await mineBlocks(1); + if (waitForSync) { + await waitForSync(); + } + if (!didAcknowledgeReceivedPayment) { + try { + await acknowledgeReceivedPayment(); + } catch { + console.info( + '→ Could not acknowledge received payment, probably already confirmed see: synonymdev/bitkit-ios#455, synonymdev/bitkit-android#797...' + ); + } + } + const moneyText = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(moneyText).toHaveText(formatSats(satsPerAddressType * (i + 1))); + + fundedAddresses.push({ type: addressType, address }); + + if (dismissBackupAfterFirstFunding && i === 0) { + try { + await dismissBackupTimedSheet({ triggerTimedSheet: true }); + } catch { + // backup sheet may already be dismissed depending on timing/platform + } + } + } + + return { + fundedAddresses, + totalFundedSats: addressTypes.length * satsPerAddressType, + }; +} + +export async function transferSavingsToSpending({ + amountSats, + waitForSync, +}: { + amountSats?: number; + waitForSync?: () => Promise; +} = {}) { + try { + await elementById('ActivitySavings').waitForDisplayed({ timeout: 5_000 }); + } catch { + await swipeFullScreen('down'); + await elementById('ActivitySavings').waitForDisplayed({ timeout: 10_000 }); + } + + await tap('ActivitySavings'); + await elementById('TransferToSpending').waitForDisplayed(); + await tap('TransferToSpending'); + await sleep(800); + + const hasSpendingIntro = await elementById('SpendingIntro-button').isDisplayed().catch(() => false); + if (hasSpendingIntro) { + await tap('SpendingIntro-button'); + await sleep(800); + } + + await elementById('SpendingAmountContinue').waitForEnabled(); + await sleep(1000); + if (typeof amountSats === 'number') { + for (const digit of String(amountSats)) { + await tap(`N${digit}`); + } + } else { + await tap('SpendingAmountMax'); + } + + await elementById('SpendingAmountContinue').waitForEnabled(); + await tap('SpendingAmountContinue'); + await sleep(1000); + await elementById('GRAB').waitForDisplayed(); + await dragOnElement('GRAB', 'right', 0.95); + await sleep(1500); + + await mineBlocks(1); + if (waitForSync) { + await waitForSync(); + } + for (let i = 0; i < 10; i++) { + try { + await elementById('TransferSuccess').waitForDisplayed(); + break; + } catch { + console.info('→ TransferSuccess not found, waiting...'); + await mineBlocks(1); + } + } + await elementById('TransferSuccess').waitForDisplayed(); + await elementById('TransferSuccess-button').waitForDisplayed(); + await tap('TransferSuccess-button'); + + try { + console.info('→ Waiting for SpendingBalanceReadyToast...'); + await waitForToast('SpendingBalanceReadyToast'); + } catch { + console.info('→ SpendingBalanceReadyToast not found, continuing...'); + } + + // verify transfer activity on savings + // see : https://github.com/synonymdev/bitkit-ios/issues/464 + if (driver.isAndroid) { + await dismissQuickPayIntro({ triggerTimedSheet: false }); + await tap('ActivitySavings'); + await expectTextWithin('Activity-1', 'Transfer', { timeout: 60_000 }); + await expectTextWithin('Activity-1', '-'); + await tap('NavigationBack'); + + } else { + await dismissBackgroundPaymentsTimedSheet({ triggerTimedSheet: false }); + await dismissQuickPayIntro({ triggerTimedSheet: true }); + } + await sleep(2000); +} + +export async function transferSpendingToSavings() { + + await tap('ActivitySpending'); + await tap('TransferToSavings'); + await sleep(800); + await tap('SavingsIntro-button'); + await tap('AvailabilityContinue'); + await sleep(1000); + await dragOnElement('GRAB', 'right', 0.95); + await elementById('TransferSuccess-button').waitForDisplayed(); + await tap('TransferSuccess-button'); + + if (driver.isAndroid) { + await doNavigationClose(); + } + + await sleep(1000); + await expectSavingsBalance(0, { condition: 'gt' }); + await expectSpendingBalance(0); + await expectTotalBalance(await getSavingsBalance()); +} + export async function getReceiveAddress(which: addressType = 'bitcoin'): Promise { await tap('Receive'); await sleep(500); @@ -733,6 +1079,10 @@ export async function fundOnchainWallet({ } } +export function formatSats(sats: number): string { + return sats.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); +} + /** * Receives onchain funds and verifies the balance. * Uses local Bitcoin RPC or Blocktank API based on BACKEND env var. @@ -746,8 +1096,7 @@ export async function receiveOnchainFunds({ blocksToMine?: number; expectHighBalanceWarning?: boolean; } = {}) { - // format sats with spaces every 3 digits - const formattedSats = sats.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + const formattedSats = formatSats(sats); // receive some first const address = await getReceiveAddress(); @@ -768,6 +1117,8 @@ export async function receiveOnchainFunds({ } export type ToastId = + | 'AddressTypeApplyingToast' + | 'AddressTypeSettingsUpdatedToast' | 'BoostSuccessToast' | 'BoostFailureToast' | 'LnurlPayAmountTooLowToast' @@ -790,9 +1141,9 @@ export type ToastId = export async function waitForToast( toastId: ToastId, - { waitToDisappear = false, dismiss = true } = {} + { waitToDisappear = false, dismiss = true , timeout = 30_000 }: { waitToDisappear?: boolean; dismiss?: boolean; timeout?: number } = {} ) { - await elementById(toastId).waitForDisplayed(); + await elementById(toastId).waitForDisplayed({ timeout }); if (waitToDisappear) { await elementById(toastId).waitForDisplayed({ reverse: true }); return; @@ -804,8 +1155,8 @@ export async function waitForToast( /** Acknowledges the received payment notification by tapping the button. */ -export async function acknowledgeReceivedPayment() { - await elementById('ReceivedTransaction').waitForDisplayed(); +export async function acknowledgeReceivedPayment( { timeout = 20_000 }: { timeout?: number } = {}) { + await elementById('ReceivedTransaction').waitForDisplayed({ timeout }); await sleep(500); await tap('ReceivedTransactionButton'); await sleep(300); diff --git a/test/helpers/electrum.ts b/test/helpers/electrum.ts index 3fe0394..763f1eb 100644 --- a/test/helpers/electrum.ts +++ b/test/helpers/electrum.ts @@ -29,6 +29,7 @@ const noopElectrum: ElectrumClient = { // 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); + console.info('→ [regtest] App synced with remote Electrum'); }, stop: async () => { // Nothing to stop for regtest diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 488f0d0..911d3e1 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -37,12 +37,11 @@ export function getRnAppPath(): string { } export function getNativeAppPath(): string { - const appFileName = driver.isIOS ? 'bitkit.app' : 'bitkit_e2e.apk'; - const fallback = path.join(__dirname, '..', '..', 'aut', appFileName); - const appPath = process.env.NATIVE_APK_PATH ?? fallback; + const appFileName = process.env.AUT_FILENAME ?? (driver.isIOS ? 'Bitkit.app' : 'bitkit_e2e.apk'); + const appPath = path.join(__dirname, '..', '..', 'aut', appFileName); if (!fs.existsSync(appPath)) { throw new Error( - `Native APK not found at: ${appPath}. Set NATIVE_APK_PATH or place it at ${fallback}` + `Native app not found at: ${appPath}. Set AUT_FILENAME or place it at aut/${appFileName}` ); } return appPath; diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index c49d52f..e8c6b7d 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -227,7 +227,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { // This scenario tests migration when wallet has funds on legacy addresses, // which triggers a sweep flow during migration. // -------------------------------------------------------------------------- - ciIt('@migration_4 - Migration with sweep (legacy p2pkh addresses)', async () => { + ciIt('@migration_4 - Migration (legacy p2pkh addresses)', async () => { // Setup wallet with funds on legacy addresses (triggers sweep on migration) const { balance } = await setupWalletWithLegacyFunds(); @@ -236,8 +236,8 @@ describe('@migration - Migration from legacy RN app to native app', () => { await driver.installApp(getNativeAppPath()); await driver.activateApp(getAppId()); - // Handle migration flow with sweep - await handleMigrationFlow({ withSweep: true }); + // Handle migration flow + await handleMigrationFlow({ withSweep: false }); // Verify migration completed (balance should be preserved after sweep, minus fees) await verifyMigrationWithSweep(balance); @@ -352,7 +352,7 @@ async function setupLegacyWallet( // 3. Transfer to spending (create channel via Blocktank) console.info('→ Step 3: Creating spending balance (channel)...'); - await transferToSpending(TRANSFER_TO_SPENDING_SATS); + await transferToSpendingRN(TRANSFER_TO_SPENDING_SATS); // Get final balance before migration const balance = await getRnTotalBalance(); @@ -780,6 +780,7 @@ async function sendRnOnchain( // Send using swipe gesture console.info(`→ About to send ${sats} sats...`); + await sleep(1000); await dragOnElement('GRAB', 'right', 0.95); await elementById('SendSuccess').waitForDisplayed(); await tap('Close'); @@ -796,7 +797,7 @@ async function sendRnOnchain( /** * Transfer savings to spending balance (create channel via Blocktank) */ -async function transferToSpending(sats: number, existingBalance = 0): Promise { +async function transferToSpendingRN(sats: number, existingBalance = 0): Promise { // Navigate via ActivitySavings -> TransferToSpending // ActivitySavings should be visible near the top of the wallet screen try { diff --git a/test/specs/multiaddress.e2e.ts b/test/specs/multiaddress.e2e.ts new file mode 100644 index 0000000..3107d38 --- /dev/null +++ b/test/specs/multiaddress.e2e.ts @@ -0,0 +1,302 @@ +import initElectrum from '../helpers/electrum'; +import { reinstallApp } from '../helpers/setup'; +import { + acknowledgeExternalSuccess, + assertAddressMatchesType, + completeOnboarding, + doNavigationClose, + dragOnElement, + elementById, + elementByIdWithin, + enterAddress, + expectSavingsBalance, + expectSpendingBalance, + expectTotalBalance, + expectTextWithin, + getTextUnder, + getReceiveAddress, + handleOver50PercentAlert, + switchAndFundEachAddressType, + swipeFullScreen, + tap, + transferSavingsToSpending, + transferSpendingToSavings, + type addressTypePreference, + getSpendingBalance, + getSavingsBalance, + getTotalBalance, + attemptRefreshOnHomeScreen, + expectText, + formatSats, + elementByText, + sleep, + waitForToast, + enterAmount, + dismissQuickPayIntro, + dismissBackgroundPaymentsTimedSheet, + getAmountUnder, +} from '../helpers/actions'; +import { ciIt } from '../helpers/suite'; +import { + checkChannelStatus, + connectToLND, + getLDKNodeID, + setupLND, + waitForPeerConnection, +} from '../helpers/lnd'; +import { lndConfig } from '../helpers/constants'; +import { ensureLocalFunds, getBitcoinRpc, getExternalAddress, mineBlocks } from '../helpers/regtest'; + +describe('@multi_address - Multi address', () => { + let electrum: Awaited> | undefined; + + before(async () => { + await ensureLocalFunds(); + electrum = await initElectrum(); + }); + + beforeEach(async () => { + await reinstallApp(); + await completeOnboarding(); + await electrum?.waitForSync(); + }); + + after(async () => { + await electrum?.stop(); + }); + + ciIt('@multi_address_1 - Receive to each address type and send max combined', async () => { + const addressTypes: addressTypePreference[] = ['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2tr']; + const satsPerAddressType = 100_000; + const { totalFundedSats } = await switchAndFundEachAddressType({ + addressTypes, + satsPerAddressType, + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + const totalBalance = await getTotalBalance(); + const savingsBalance = await getSavingsBalance(); + const spendingBalance = await getSpendingBalance(); + await expect(savingsBalance).toEqual(totalFundedSats); + await expect(spendingBalance).toEqual(0); + await expect(totalBalance).toEqual(totalFundedSats); + + const coreAddress = await getExternalAddress(); + await enterAddress(coreAddress); + await tap('AvailableAmount'); + await tap('ContinueAmount'); + await dragOnElement('GRAB', 'right', 0.95); + await handleOver50PercentAlert(); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + await mineBlocks(1); + await electrum?.waitForSync(); + + await expectTotalBalance(0); + await expectSavingsBalance(0); + await expectSpendingBalance(0); + + const totalBalanceAfter = await getTotalBalance(); + const savingsBalanceAfter = await getSavingsBalance(); + const spendingBalanceAfter = await getSpendingBalance(); + await expect(totalBalanceAfter).toEqual(0); + await expect(savingsBalanceAfter).toEqual(0); + await expect(spendingBalanceAfter).toEqual(0); + }); + + ciIt( + '@multi_address_2, @regtest_only - Receive to each address type, transfer all to spending, close channel to taproot', + async () => { + const addressTypes: addressTypePreference[] = ['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2tr']; + // const addressTypes: addressTypePreference[] = ['p2tr']; + const satsPerAddressType = 25_000; + await switchAndFundEachAddressType({ + addressTypes, + satsPerAddressType, + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + // Last funded type is Taproot, keep it as primary for channel open/close. + const taprootAddressBeforeClose = await getReceiveAddress(); + assertAddressMatchesType(taprootAddressBeforeClose, 'p2tr'); + await swipeFullScreen('down'); + await swipeFullScreen('down'); + + await mineBlocks(1); + await electrum?.waitForSync(); + + await transferSavingsToSpending({ + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + await expectSpendingBalance(0, { condition: 'gt' }); + await expectSavingsBalance(0); + + if (driver.isAndroid) { + // pull to refresh due to: + // https://github.com/synonymdev/bitkit-android/issues/810 + await attemptRefreshOnHomeScreen(); + await attemptRefreshOnHomeScreen(); + } + + await transferSpendingToSavings(); + + await mineBlocks(1); + await electrum?.waitForSync(); + await expectSavingsBalance(0, { condition: 'gt' }); + await expectSpendingBalance(0); + const savingsBalanceAfter = await getSavingsBalance(); + await expectTotalBalance(savingsBalanceAfter); + + const taprootAddressAfterClose = await getReceiveAddress(); + assertAddressMatchesType(taprootAddressAfterClose, 'p2tr'); + await swipeFullScreen('down'); + + // check in address viewer all savings are in taproot address + await tap('HeaderMenu'); + await tap('DrawerSettings'); + await sleep(1000); + await tap('AdvancedSettings'); + await sleep(1000); + await tap('AddressViewer'); + await sleep(1000); + await elementByText('Taproot').click(); + await expectText(formatSats(savingsBalanceAfter)); + } + ); + + ciIt('@multi_address_3 - Receive to each type, send almost max, verify change to primary, then RBF', async () => { + const addressTypes: addressTypePreference[] = ['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2tr']; + const satsPerAddressType = 10_000; + const sendAmountSats = 37_000; + await switchAndFundEachAddressType({ + addressTypes, + satsPerAddressType, + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + const coreAddress = await getExternalAddress(); + await enterAddress(coreAddress); + await enterAmount(sendAmountSats); + await expectText(formatSats(sendAmountSats)); + await tap('ContinueAmount'); + await dragOnElement('GRAB', 'right', 0.95); + await handleOver50PercentAlert().catch(async () => {}); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + + await sleep(1000); + await swipeFullScreen('up'); + await swipeFullScreen('up'); + await tap('ActivityShort-0'); + await expectTextWithin('ActivityAmount', formatSats(sendAmountSats)); + const oldFee = await (await elementByIdWithin('ActivityFee', 'MoneyText')).getText(); + await tap('ActivityTxDetails'); + const oldTxId = await getTextUnder('TXID'); + await tap('NavigationBack'); + + await tap('BoostButton'); + await elementById('RBFBoost').waitForDisplayed(); + await tap('CustomFeeButton'); + await tap('Plus'); + await tap('Minus'); + await tap('RecommendedFeeButton'); + await dragOnElement('GRAB', 'right', 0.95); + await waitForToast('BoostSuccessToast'); + + await tap('ActivityShort-0'); + await expectTextWithin('ActivityAmount', formatSats(sendAmountSats)); + const newFee = await (await elementByIdWithin('ActivityFee', 'MoneyText')).getText(); + await tap('ActivityTxDetails'); + const newTxId = await getTextUnder('TXID'); + await expect(Number(oldFee.replace(' ', '')) < Number(newFee.replace(' ', ''))).toBe(true); + await expect(oldTxId !== newTxId).toBe(true); + await elementById('RBFBoosted').waitForDisplayed(); + await doNavigationClose(); + + await sleep(1000); + await swipeFullScreen('down'); + await swipeFullScreen('down'); + + await mineBlocks(1); + await electrum?.waitForSync(); + const remainingTotal = await getTotalBalance(); + await expect(remainingTotal).toBeGreaterThan(0); + + // verify change is in taproot address + await tap('HeaderMenu'); + await tap('DrawerSettings'); + await sleep(1000); + await tap('AdvancedSettings'); + await sleep(1000); + await tap('AddressViewer'); + await sleep(1000); + await elementByText('Taproot').click(); + await elementByText('Change Addresses').click(); + // temporary disabled change address ldk-node issue + // await expectText(formatSats(remainingTotal)); + }); + + ciIt( + '@multi_address_4 - Receive to each type, open external channel with max, keep Legacy untouched', + async () => { + const rpc = getBitcoinRpc(); + const addressTypes: addressTypePreference[] = ['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2tr']; + const satsPerAddressType = 25_000; + await switchAndFundEachAddressType({ + addressTypes, + satsPerAddressType, + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + const { lnd, lndNodeID } = await setupLND(rpc, lndConfig); + await electrum?.waitForSync(); + const ldkNodeId = await getLDKNodeID(); + await connectToLND(lndNodeID, { navigationClose: false }); + await waitForPeerConnection(lnd, ldkNodeId); + + await tap('ExternalAmountMax'); + await sleep(1000); + const channelSize = await getAmountUnder('ExternalAmountNumberField'); + await tap('ExternalAmountContinue'); + await sleep(1000); + await dragOnElement('GRAB', 'right', 0.95); + await acknowledgeExternalSuccess(); + + await mineBlocks(6); + await electrum?.waitForSync(); + await waitForToast('SpendingBalanceReadyToast'); + if (driver.isIOS) { + await dismissBackgroundPaymentsTimedSheet({ triggerTimedSheet: true }); + await dismissQuickPayIntro({ triggerTimedSheet: true }) + } else { + await dismissQuickPayIntro({ triggerTimedSheet: true }); + } + await checkChannelStatus({ size: formatSats(channelSize) }); + + // savings has all legacy funds + const savingsBalance = await getSavingsBalance(); + await expect(savingsBalance).toEqual(satsPerAddressType); + + await tap('HeaderMenu'); + await tap('DrawerSettings'); + await sleep(1000); + await tap('AdvancedSettings'); + await sleep(1000); + await tap('AddressViewer'); + await sleep(1000); + await elementByText('Legacy').click(); + await expectText(formatSats(satsPerAddressType)); + await doNavigationClose(); + } + ); +}); diff --git a/test/specs/onchain.e2e.ts b/test/specs/onchain.e2e.ts index 55a0b74..160a6e1 100644 --- a/test/specs/onchain.e2e.ts +++ b/test/specs/onchain.e2e.ts @@ -22,6 +22,8 @@ import { handleOver50PercentAlert, handleOver100Alert, acknowledgeReceivedPayment, + enterAmount, + formatSats, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; import { @@ -51,15 +53,14 @@ describe('@onchain - Onchain', () => { ciIt('@onchain_1 - Receive and send some out', async () => { // receive some first - await receiveOnchainFunds({ sats: 100_000_000, expectHighBalanceWarning: true }); + const satsToReceive = 100_000_000; + await receiveOnchainFunds({ sats: satsToReceive, expectHighBalanceWarning: true }); // then send out 10 000 const coreAddress = await getExternalAddress(); console.info({ coreAddress }); await enterAddress(coreAddress); - await tap('N1'); - await tap('N000'); - await tap('N0'); + await enterAmount(10_000); await tap('ContinueAmount'); await dragOnElement('GRAB', 'right', 0.95); @@ -82,7 +83,7 @@ describe('@onchain - Onchain', () => { await expectTextWithin(sentShort, 'Sent'); await expectTextWithin(receiveShort, '+'); await expectTextWithin(receiveShort, 'Received'); - await expectTextWithin(receiveShort, '100 000 000'); + await expectTextWithin(receiveShort, formatSats(satsToReceive)); await swipeFullScreen('up'); await tap('ActivityShowAll'); @@ -92,7 +93,7 @@ describe('@onchain - Onchain', () => { await expectTextWithin(sentDetail, 'Sent'); await expectTextWithin(receiveDetail, '+'); await expectTextWithin(receiveDetail, 'Received'); - await expectTextWithin(receiveDetail, '100 000 000'); + await expectTextWithin(receiveDetail, formatSats(satsToReceive)); }); // Test plan @@ -323,4 +324,5 @@ describe('@onchain - Onchain', () => { // await elementByText('OUTPUT').waitForDisplayed(); // await elementByText('OUTPUT (2)').waitForDisplayed({ reverse: true }); }); + }); diff --git a/wdio.conf.ts b/wdio.conf.ts index c026797..eb78d01 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -5,6 +5,11 @@ const isAndroid = process.env.PLATFORM === 'android'; const iosDeviceName = process.env.SIMULATOR_NAME || 'iPhone 17'; const iosPlatformVersion = process.env.SIMULATOR_OS_VERSION || '26.0.1'; +const autDir = path.join(__dirname, 'aut'); +const autFilename = process.env.AUT_FILENAME; +const androidApp = path.join(autDir, autFilename || 'bitkit_e2e.apk'); +const iosApp = path.join(autDir, autFilename || 'Bitkit.app'); + export const config: WebdriverIO.Config = { // // ==================== @@ -66,7 +71,7 @@ export const config: WebdriverIO.Config = { 'appium:automationName': 'UiAutomator2', 'appium:deviceName': 'Pixel_6', 'appium:platformVersion': '13.0', - 'appium:app': path.join(__dirname, 'aut', 'bitkit_e2e.apk'), + 'appium:app': androidApp, 'appium:autoGrantPermissions': true, // 'appium:waitForIdleTimeout': 1000, } @@ -76,7 +81,7 @@ export const config: WebdriverIO.Config = { 'appium:udid': process.env.SIMULATOR_UDID || 'auto', 'appium:deviceName': iosDeviceName, ...(iosPlatformVersion ? { 'appium:platformVersion': iosPlatformVersion } : {}), - 'appium:app': path.join(__dirname, 'aut', 'Bitkit.app'), + 'appium:app': iosApp, 'appium:autoGrantPermissions': true, 'appium:autoAcceptAlerts': false, // 'appium:fullReset': true,