diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dc57326..eed4c84 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' @@ -214,8 +218,12 @@ services: - '6080:6080' # noVNC web viewer - '9003:9003' # MCP server SSE transport environment: - - MACOS=1 + - MACOS=${TREZOR_MACOS:-1} - REGTEST_RPC_URL=http://host.docker.internal:43782 + # host.docker.internal is implicit on Docker Desktop but not on Linux, where + # the emulator otherwise cannot reach the published bitcoind port. + extra_hosts: + - 'host.docker.internal:host-gateway' volumes: - './.trezor-user-env/trezor-suite:/trezor-user-env/trezor-suite' - './.trezor-user-env/logs/screens:/trezor-user-env/logs/screens' diff --git a/scripts/trezor-controller.py b/scripts/trezor-controller.py index fca4e45..ff5701e 100755 --- a/scripts/trezor-controller.py +++ b/scripts/trezor-controller.py @@ -95,12 +95,36 @@ async def raw(payload: str) -> None: await send(parsed) +def _translate_bridge_timeouts() -> None: + """Raise trezorlib's Timeout for a slow bridge, not the requests one. + + protocol_v1.probe drains stale responses with a 0.1s read and relies on + catching Timeout to stop. call_bridge lets the requests exception through, + which only shows up once the bridge is reached over a link slower than that + read timeout. + """ + import requests + from trezorlib.transport import Timeout, bridge + + original = bridge.call_bridge + + def call_bridge(*args, **kwargs): + try: + return original(*args, **kwargs) + except requests.exceptions.Timeout as exc: + raise Timeout(str(exc)) from exc + + bridge.call_bridge = call_bridge + + def get_address() -> None: from trezorlib import btc, messages from trezorlib.client import get_default_client from trezorlib.tools import parse_path from trezorlib.transport.bridge import BridgeTransport + _translate_bridge_timeouts() + transport = None for _ in range(30): transport = next(iter(BridgeTransport.enumerate()), None) diff --git a/scripts/trezor-emulator b/scripts/trezor-emulator index 8f5b1b8..28fde6b 100755 --- a/scripts/trezor-emulator +++ b/scripts/trezor-emulator @@ -46,6 +46,32 @@ compose() { docker compose -f "$COMPOSE_FILE" "$@" } +# The container may live on another machine, with its controller websocket and +# bridge reached over a port forward. The controller script only needs python3 +# with `websockets`, so it runs here instead of via `docker exec`. +remote_mode() { + [[ "${TREZOR_REMOTE:-0}" == "1" ]] +} + +# Callers fill CONTROLLER_ENVS with KEY=VALUE entries first. A global rather +# than a nameref, which bash 3.2 on macOS does not have. +CONTROLLER_ENVS=() + +run_controller_script() { + if remote_mode; then + env "${CONTROLLER_ENVS[@]}" "${TREZOR_LOCAL_PYTHON:-python3}" "$CONTROLLER_SCRIPT" "$@" + return + fi + + local docker_envs=() kv + for kv in "${CONTROLLER_ENVS[@]}"; do + docker_envs+=(-e "$kv") + done + docker exec -i "${docker_envs[@]}" \ + "$TREZOR_CONTAINER" "${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" - "$@" \ + < "$CONTROLLER_SCRIPT" +} + compose_with_profile() { if [[ -n "${TREZOR_PROFILE:-}" ]]; then compose --profile "$TREZOR_PROFILE" "$@" @@ -57,6 +83,8 @@ compose_with_profile() { resolve_container() { local compose_id + remote_mode && return 0 + if [[ -n "${TREZOR_CONTAINER:-}" ]] && docker inspect "$TREZOR_CONTAINER" >/dev/null 2>&1; then return 0 fi @@ -75,6 +103,7 @@ container_exists() { } container_running() { + remote_mode && return 0 resolve_container || return 1 [[ "$(docker inspect -f '{{.State.Running}}' "$TREZOR_CONTAINER" 2>/dev/null)" == "true" ]] } @@ -106,11 +135,15 @@ prepare_dirs() { } random_mnemonic() { - docker exec -i "$TREZOR_CONTAINER" /trezor-user-env/.venv/bin/python3 - <<'PY' -from mnemonic import Mnemonic + local py="${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" + local script='from mnemonic import Mnemonic +print(Mnemonic("english").generate(strength=128))' -print(Mnemonic("english").generate(strength=128)) -PY + if remote_mode; then + "${TREZOR_LOCAL_PYTHON:-python3}" -c "$script" + else + docker exec -i "$TREZOR_CONTAINER" "$py" -c "$script" + fi } resolve_mnemonic() { @@ -141,28 +174,32 @@ controller() { echo "Using Trezor mnemonic: $mnemonic" >&2 fi - docker exec -i \ - -e TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" \ - -e TREZOR_MODEL="${TREZOR_MODEL:-T2T1}" \ - -e TREZOR_FIRMWARE="${TREZOR_FIRMWARE:-2-main}" \ - -e TREZOR_BRIDGE_VERSION="${TREZOR_BRIDGE_VERSION:-node-bridge}" \ - -e TREZOR_MNEMONIC="$mnemonic" \ - -e TREZOR_PIN="${TREZOR_PIN:-}" \ - -e TREZOR_PASSPHRASE_PROTECTION="${TREZOR_PASSPHRASE_PROTECTION:-false}" \ - -e TREZOR_LABEL="${TREZOR_LABEL:-Bitkit Test Trezor}" \ - -e TREZOR_NEEDS_BACKUP="${TREZOR_NEEDS_BACKUP:-false}" \ - -e TREZOR_WIPE="${TREZOR_WIPE:-true}" \ - "$TREZOR_CONTAINER" "$python_bin" - "$@" < "$CONTROLLER_SCRIPT" + local envs=( + TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" + TREZOR_MODEL="${TREZOR_MODEL:-T2T1}" + TREZOR_FIRMWARE="${TREZOR_FIRMWARE:-2-main}" + TREZOR_BRIDGE_VERSION="${TREZOR_BRIDGE_VERSION:-node-bridge}" + TREZOR_MNEMONIC="$mnemonic" + TREZOR_PIN="${TREZOR_PIN:-}" + TREZOR_PASSPHRASE_PROTECTION="${TREZOR_PASSPHRASE_PROTECTION:-false}" + TREZOR_LABEL="${TREZOR_LABEL:-Bitkit Test Trezor}" + TREZOR_NEEDS_BACKUP="${TREZOR_NEEDS_BACKUP:-false}" + TREZOR_WIPE="${TREZOR_WIPE:-true}" + ) + CONTROLLER_ENVS=("${envs[@]}") + TREZOR_CONTAINER_PYTHON="$python_bin" run_controller_script "$@" } get_address() { local address_path="${TREZOR_ADDRESS_PATH:-m/84h/1h/0h/0/0}" - docker exec -i \ - -e TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" \ - -e TREZOR_ADDRESS_COIN="${TREZOR_ADDRESS_COIN:-Regtest}" \ - -e TREZOR_ADDRESS_PATH="$address_path" \ - "$TREZOR_CONTAINER" "${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" - get-address < "$CONTROLLER_SCRIPT" + local envs=( + TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" + TREZOR_ADDRESS_COIN="${TREZOR_ADDRESS_COIN:-Regtest}" + TREZOR_ADDRESS_PATH="$address_path" + ) + CONTROLLER_ENVS=("${envs[@]}") + run_controller_script get-address } print_address() { @@ -211,6 +248,11 @@ install_apple_silicon_sdl_packages() { } start_env() { + if remote_mode; then + wait_for_controller + return + fi + prepare_dirs # Force recreate so a stopped container with a deleted compose network @@ -371,6 +413,8 @@ stop() { controller stop || true fi + remote_mode && return 0 + compose_with_profile stop "$TREZOR_SERVICE" } diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f40e612..af170ba 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,64 @@ 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() { + // AvailableAmount exposes its value through a nested MoneyText rather than a + // plain static text, and tapping it raises a toast over the element. + const availableText = await (await elementByIdWithin('AvailableAmount', 'MoneyText')).getText(); + const availableAmountSats = Number(availableText.replace(/[^\d]/g, '')); + await tap('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/test/specs/settings.e2e.ts b/test/specs/settings.e2e.ts index 28fc6c1..14d3996 100644 --- a/test/specs/settings.e2e.ts +++ b/test/specs/settings.e2e.ts @@ -45,7 +45,7 @@ describe('@settings - Settings', () => { } await expect(fiatSymbol).toHaveText('$'); if (driver.isIOS) { - await waitForToast('BalanceUnitSwitchedToast'); + await waitForToast('BalanceUnitSwitchedToast', { waitToDisappear: true }); } // - change settings (currency to EUR) // 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