diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dc57326..d2ea92b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -106,6 +106,10 @@ services: - '--noseedbackup' - '--alias=lnd' - '--externalip=${LND_EXTERNAL_IP:-127.0.0.1}' + # LND issues its own cert on first start, covering only 127.0.0.1, ::1 and + # its container address. gRPC and REST verify the hostname, so reaching it + # from another machine needs that address in the SAN list. + - '--tlsextraip=${LND_EXTERNAL_IP:-127.0.0.1}' - '--bitcoin.active' - '--bitcoin.regtest' - '--bitcoin.node=bitcoind' diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f40e612..143f16f 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -883,7 +883,10 @@ export async function waitForTextToDisappear(texts: string[], timeout: number) { async function assertAddressTypeSwitchFeedback() { // await waitForToast('AddressTypeApplyingToast', { dismiss: false }); - await waitForToast('AddressTypeSettingsUpdatedToast'); + await waitForToast('AddressTypeSettingsUpdatedToast', { + dismiss: driver.isAndroid, + timeout: 120_000, + }); } export async function switchPrimaryAddressType(nextType: addressTypePreference) { @@ -1265,6 +1268,61 @@ export async function waitForToast( } } +async function waitForTransientToastAfterAction( + toastId: ToastId, + action: () => Promise +) { + if (driver.isAndroid) { + await action(); + await waitForToast(toastId); + return; + } + + // These feedback toasts live for 1.5 seconds. XCUITest's default all-match lookup can + // find one, then lose it while rebinding the accessibility snapshot. Scope the faster + // single-match lookup to this top-level element so normal nested lookups stay unchanged. + await driver.updateSettings({ useFirstMatch: true }); + try { + await browser.waitUntil( + async () => { + await action(); + try { + const toast = await elementById(toastId); + return Boolean(toast.elementId); + } catch { + return false; + } + }, + { + timeout: 30_000, + interval: 250, + timeoutMsg: `Timed out waiting for transient toast: ${toastId}`, + } + ); + } finally { + await driver.updateSettings({ useFirstMatch: false }); + } +} + +export async function exceedAmountInputCap(maxAmountSats: number) { + await enterAmount(maxAmountSats); + await verifyAmountToSend(maxAmountSats); + await waitForTransientToastAfterAction('SendAmountExceededToast', async () => { + await tap('N1'); + }); + await verifyAmountToSend(maxAmountSats); +} + +export async function exceedAvailableAmountInputCap() { + await tap('AvailableAmount'); + const availableAmountSats = await getAmountUnder('AvailableAmount'); + await verifyAmountToSend(availableAmountSats); + await waitForTransientToastAfterAction('SendAmountExceededToast', async () => { + await tap('N1'); + }); + await verifyAmountToSend(availableAmountSats); +} + /** Acknowledges the received payment notification by tapping the button. */ export async function acknowledgeReceivedPayment({ timeout = 30_000 }: { timeout?: number } = {}) { diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 77b53c0..5ba8e5c 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -42,11 +42,25 @@ export function grantIOSCameraPermission(appIdParam?: string) { } } +export async function activateAppWithEnv(appId: string) { + // processArguments in the session capabilities only apply to the first launch, + // so a relaunch would lose E2E_LOCAL_HOST and the app would fall back to the + // Info.plist value baked in at build time. + if (driver.isIOS && process.env.E2E_LOCAL_HOST) { + await driver.execute('mobile: launchApp', { + bundleId: appId, + environment: { E2E_LOCAL_HOST: process.env.E2E_LOCAL_HOST }, + }); + return; + } + await driver.activateApp(appId); +} + export async function launchFreshApp() { const appId = getAppId(); await driver.terminateApp(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); await sleep(3000); } @@ -62,7 +76,7 @@ export async function reinstallApp() { resetBootedIOSKeychain(); await driver.installApp(appPath); grantIOSCameraPermission(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); } export function getRnAppPath(): string { @@ -92,7 +106,7 @@ export async function reinstallAppFromPath(appPath: string, appId: string = getA resetBootedIOSKeychain(); await driver.installApp(appPath); grantIOSCameraPermission(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); } /** diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index 5bfa85e..5c07b31 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -25,6 +25,7 @@ import { acknowledgeReceivedPayment, acknowledgeExternalSuccess, enterAmount, + exceedAmountInputCap, } from '../helpers/actions'; import { reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; @@ -92,7 +93,7 @@ describe('@lnurl - LNURL', () => { lightning: { backend: 'lnd', config: { - hostname: '127.0.0.1:8080', + hostname: `${lndConfig.restHost}:${lndConfig.restPort}`, macaroon: lndConfig.macaroonPath, cert: lndConfig.tls, }, @@ -175,18 +176,10 @@ describe('@lnurl - LNURL', () => { await enterAddressViaScanPrompt(payRequest1.encoded, { acceptCameraPermission: false }); await expectTextWithin('SendNumberField', '0'); - // Check that 149 sats is below minimum and 201 sats is above maximum (both rejected) - try { - await enterAmount(201); - await waitForToast('SendAmountExceededToast', { dismiss: driver.isAndroid }); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - // tap on 1 fast to trigger the toast - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast', { dismiss: driver.isAndroid }); - } + // Check that input above the 200 sat maximum is capped and 149 sats is rejected as below minimum + await exceedAmountInputCap(200); - await multiTap('NRemove', 3); // remove "201" + await multiTap('NRemove', 3); // remove "200" await enterAmount(149); await expectTextWithin('SendNumberField', '149'); await tap('ContinueAmount'); diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 6dc165f..b41e7e5 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -29,6 +29,7 @@ import { grantIOSCameraPermission, reinstallAppFromPath, resetBootedIOSKeychain, + activateAppWithEnv, } from '../helpers/setup'; import { getAppId } from '../helpers/constants'; import initElectrum, { ElectrumClient } from '../helpers/electrum'; @@ -172,7 +173,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app from: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Restore wallet with mnemonic (uses custom flow to handle backup sheet) await restoreWallet(mnemonic!, { @@ -197,7 +198,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); @@ -217,7 +218,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); @@ -239,7 +240,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); diff --git a/test/specs/receive-ln-payments.e2e.ts b/test/specs/receive-ln-payments.e2e.ts index cf5aa5d..9931634 100644 --- a/test/specs/receive-ln-payments.e2e.ts +++ b/test/specs/receive-ln-payments.e2e.ts @@ -22,6 +22,7 @@ import { } from '../helpers/actions'; import { payInvoice } from '../helpers/regtest'; import { getAppId } from '../helpers/constants'; +import { activateAppWithEnv } from '../helpers/setup'; const PAYMENT_COUNT = Number(process.env.PAYMENT_COUNT || '21'); const PAYMENT_AMOUNT = Number(process.env.PAYMENT_AMOUNT || '10'); @@ -39,7 +40,7 @@ function extractLightningInvoice(uri: string): string { describe('Receive LN payments (utility)', () => { before(async () => { const appId = getAppId(); - await driver.activateApp(appId); + await activateAppWithEnv(appId); await sleep(3000); }); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index 7be8943..cb32933 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -25,7 +25,7 @@ import { editRecipientAddress, typeRecipientInput, tap, - enterAmount, + exceedAvailableAmountInputCap, verifyAmountToSend, } from '../helpers/actions'; import { lndConfig } from '../helpers/constants'; @@ -145,14 +145,7 @@ describe('@send - Send', () => { // type amount over balance and verify you cannot continue await tap('AddressContinue'); - await enterAmount(amount + 1); - try { - await waitForToast('SendAmountExceededToast'); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast'); - } + await exceedAvailableAmountInputCap(); await tap('NavigationBack'); // check validation for unified invoice when balance is enough (10_000 sats) @@ -265,14 +258,7 @@ describe('@send - Send', () => { const { paymentRequest: invoice0 } = await lnd.addInvoice({}); console.info({ invoice0 }); await enterAddress(invoice0); - await enterAmount(10_000 + 1); - try { - await waitForToast('SendAmountExceededToast'); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast'); - } + await exceedAvailableAmountInputCap(); await swipeFullScreen('down'); // send to onchain address diff --git a/wdio.conf.ts b/wdio.conf.ts index dc12f66..8bcdc16 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -18,6 +18,11 @@ const appiumNewCommandTimeout = Number.parseInt( process.env.APPIUM_NEW_COMMAND_TIMEOUT ?? '300', 10 ); +const wdaLaunchTimeout = Number.parseInt(process.env.WDA_LAUNCH_TIMEOUT ?? '300000', 10); +const connectionRetryTimeout = Number.parseInt( + process.env.WDIO_CONNECTION_RETRY_TIMEOUT ?? '360000', + 10 +); export const config: WebdriverIO.Config = { // @@ -108,8 +113,8 @@ export const config: WebdriverIO.Config = { // 🩹 Stability improvements 'appium:newCommandTimeout': 300, - 'appium:wdaLaunchTimeout': 300000, - 'appium:wdaConnectionTimeout': 300000, + 'appium:wdaLaunchTimeout': wdaLaunchTimeout, + 'appium:wdaConnectionTimeout': wdaLaunchTimeout, 'appium:wdaStartupRetries': 3, 'appium:wdaStartupRetryInterval': 5000, }, @@ -122,7 +127,7 @@ export const config: WebdriverIO.Config = { // Define all options that are relevant for the WebdriverIO instance here // // Level of logging verbosity: trace | debug | info | warn | error | silent - logLevel: 'warn', + logLevel: (process.env.WDIO_LOG_LEVEL as WebdriverIO.Config['logLevel']) ?? 'warn', // // Set specific log levels per logger // loggers: @@ -153,8 +158,8 @@ export const config: WebdriverIO.Config = { // // Default timeout in milliseconds for request // if browser driver or grid doesn't send response - // Must be >= wdaLaunchTimeout (300000) to allow WDA time to start - connectionRetryTimeout: 360000, + // Must be >= wdaLaunchTimeout to allow WDA time to start + connectionRetryTimeout, // // Default request retries count connectionRetryCount: 3, @@ -163,7 +168,11 @@ export const config: WebdriverIO.Config = { // Services take over a specific job you don't want to take care of. They enhance // your test setup with almost no effort. Unlike plugins, they don't add new // commands. Instead, they hook themselves up into the test process. - services: ['appium'], + services: [ + // Appium's own log is the only place that says whether WDA is building, + // launching or failing to connect. + ['appium', { logPath: process.env.APPIUM_LOG_PATH ?? './artifacts' }], + ], // Framework you want to run your specs with. // The following are supported: Mocha, Jasmine, and Cucumber