Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
24 changes: 24 additions & 0 deletions scripts/trezor-controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 65 additions & 21 deletions scripts/trezor-emulator
Original file line number Diff line number Diff line change
Expand Up @@ -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" "$@"
Expand All @@ -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
Expand All @@ -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" ]]
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -371,6 +413,8 @@ stop() {
controller stop || true
fi

remote_mode && return 0

compose_with_profile stop "$TREZOR_SERVICE"
}

Expand Down
63 changes: 62 additions & 1 deletion test/helpers/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1265,6 +1268,64 @@ export async function waitForToast(
}
}

async function waitForTransientToastAfterAction(
toastId: ToastId,
action: () => Promise<void>
) {
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 } = {}) {
Expand Down
20 changes: 17 additions & 3 deletions test/helpers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
17 changes: 5 additions & 12 deletions test/specs/lnurl.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
acknowledgeReceivedPayment,
acknowledgeExternalSuccess,
enterAmount,
exceedAmountInputCap,
} from '../helpers/actions';
import { reinstallApp } from '../helpers/setup';
import { ciIt } from '../helpers/suite';
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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');
Expand Down
Loading