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/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..8c8d6f0 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -637,6 +637,279 @@ 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, + mineAttempts = 10, +}: { + amountSats?: number; + waitForSync?: () => Promise; + mineAttempts?: number; +} = {}) { + 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({ timeout: 15_000 }); + await tap('TransferToSpending'); + await sleep(800); + + const hasSpendingIntro = await elementById('SpendingIntro-button').isDisplayed().catch(() => false); + if (hasSpendingIntro) { + await tap('SpendingIntro-button'); + await sleep(800); + } + + if (typeof amountSats === 'number') { + for (const digit of String(amountSats)) { + await tap(`N${digit}`); + } + } else { + await tap('SpendingAmountMax'); + } + + await elementById('SpendingAmountContinue').waitForEnabled({ timeout: 20_000 }); + await tap('SpendingAmountContinue'); + await sleep(1000); + await elementById('GRAB').waitForDisplayed({ timeout: 90_000 }); + await dragOnElement('GRAB', 'right', 0.95); + await sleep(1500); + + if (driver.isAndroid) { + await handleAndroidAlert().catch(() => undefined); + } + + for (let i = 0; i < mineAttempts; i++) { + const transferSuccessVisible = await elementById('TransferSuccess-button') + .isDisplayed() + .catch(() => false); + if (transferSuccessVisible) { + break; + } + await mineBlocks(1); + if (waitForSync) { + await waitForSync(); + } + } + + await elementById('TransferSuccess-button').waitForDisplayed({ timeout: 20_000 }); + await tap('TransferSuccess-button'); + if (waitForSync) { + await waitForSync(); + } + await sleep(1000); +} + +export async function transferSpendingToSavingsAndCloseChannel({ + waitForSync, + blocksToMineAfterClose = 6, +}: { + waitForSync?: () => Promise; + blocksToMineAfterClose?: number; +} = {}) { + await doNavigationClose().catch(() => undefined); + + let hasSpendingActivity = false; + for (let attempt = 0; attempt < 4; attempt++) { + hasSpendingActivity = await elementById('ActivitySpending') + .isDisplayed() + .catch(() => false); + if (hasSpendingActivity) { + break; + } + await swipeFullScreen('up'); + } + if (!hasSpendingActivity) { + throw new Error('ActivitySpending not found on home screen'); + } + + await tap('ActivitySpending'); + const hasTransferToSavingsById = await elementById('TransferToSavings') + .isDisplayed() + .catch(() => false); + if (hasTransferToSavingsById) { + await tap('TransferToSavings'); + } else { + await elementByText('Transfer to savings').waitForDisplayed({ timeout: 20_000 }); + await elementByText('Transfer to savings').click(); + } + await sleep(800); + + const hasSavingsIntro = await elementById('SavingsIntro-button').isDisplayed().catch(() => false); + if (hasSavingsIntro) { + await tap('SavingsIntro-button'); + await sleep(800); + } + + const hasAvailabilityContinue = await elementById('AvailabilityContinue') + .isDisplayed() + .catch(() => false); + if (hasAvailabilityContinue) { + await tap('AvailabilityContinue'); + await sleep(800); + } + + await dragOnElement('GRAB', 'right', 0.95); + await elementById('TransferSuccess-button').waitForDisplayed({ timeout: 120_000 }); + await tap('TransferSuccess-button'); + + if (blocksToMineAfterClose > 0) { + await mineBlocks(blocksToMineAfterClose); + } + if (waitForSync) { + await waitForSync(); + } + await sleep(1000); +} + export async function getReceiveAddress(which: addressType = 'bitcoin'): Promise { await tap('Receive'); await sleep(500); @@ -733,6 +1006,10 @@ export async function fundOnchainWallet({ } } +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 +1023,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 +1044,8 @@ export async function receiveOnchainFunds({ } export type ToastId = + | 'AddressTypeApplyingToast' + | 'AddressTypeSettingsUpdatedToast' | 'BoostSuccessToast' | 'BoostFailureToast' | 'LnurlPayAmountTooLowToast' @@ -804,8 +1082,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..58e0a56 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -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(); @@ -796,7 +796,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/onchain.e2e.ts b/test/specs/onchain.e2e.ts index 55a0b74..265472b 100644 --- a/test/specs/onchain.e2e.ts +++ b/test/specs/onchain.e2e.ts @@ -1,6 +1,7 @@ import initElectrum from '../helpers/electrum'; import { reinstallApp } from '../helpers/setup'; import { + assertAddressMatchesType, completeOnboarding, dragOnElement, elementById, @@ -22,6 +23,9 @@ import { handleOver50PercentAlert, handleOver100Alert, acknowledgeReceivedPayment, + switchAndFundEachAddressType, + transferSavingsToSpending, + transferSpendingToSavingsAndCloseChannel, } from '../helpers/actions'; import { ciIt } from '../helpers/suite'; import { @@ -323,4 +327,105 @@ describe('@onchain - Onchain', () => { // await elementByText('OUTPUT').waitForDisplayed(); // await elementByText('OUTPUT (2)').waitForDisplayed({ reverse: true }); }); + + ciIt( + '@onchain_multi_address_1 - Receive to each address type and send max combined', + async () => { + const addressTypes: ('p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' | 'p2tr')[] = [ + 'p2pkh', + 'p2sh-p2wpkh', + 'p2wpkh', + 'p2tr', + ]; + const satsPerAddressType = 100_000; + const { totalFundedSats } = await switchAndFundEachAddressType({ + addressTypes, + satsPerAddressType, + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + const expectedTotal = totalFundedSats + .toString() + .replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + + const totalBalance = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(totalBalance).toHaveText(expectedTotal); + + 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(); + + const totalBalanceAfter = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(totalBalanceAfter).toHaveText('0'); + } + ); + + ciIt( + '@onchain_multi_address_2 - Receive to each address type, transfer all to spending, close channel to taproot', + async () => { + const addressTypes: ('p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' | 'p2tr')[] = [ + 'p2pkh', + 'p2sh-p2wpkh', + 'p2wpkh', + '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 transferSavingsToSpending({ + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + // Wait for spending balance to become available before cooperative close. + let spendingReady = false; + for (let i = 0; i < 12; i++) { + const spendingBalanceText = await ( + await elementByIdWithin('ActivitySpending', 'MoneyText') + ).getText(); + const spendingSats = Number(spendingBalanceText.replace(/[^\d]/g, '')); + if (spendingSats > 0) { + spendingReady = true; + break; + } + await mineBlocks(1); + await electrum?.waitForSync(); + await sleep(1200); + } + expect(spendingReady).toBe(true); + + await transferSpendingToSavingsAndCloseChannel({ + waitForSync: async () => { + await electrum?.waitForSync(); + }, + }); + + const totalBalanceAfterClose = await elementByIdWithin('TotalBalance-primary', 'MoneyText'); + await expect(totalBalanceAfterClose).not.toHaveText('0'); + + const taprootAddressAfterClose = await getReceiveAddress(); + assertAddressMatchesType(taprootAddressAfterClose, 'p2tr'); + await swipeFullScreen('down'); + } + ); }); 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,