From 13db1a703d8d26e4f42e427843ece7bea3bf77d2 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Thu, 27 Aug 2026 15:45:34 +0200 Subject: [PATCH 1/3] test: add a hosted-runner trezor-emu path alongside the self-hosted one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `trezor-emu` needs Docker and the iOS Simulator on one machine, which GitHub-hosted macOS runners cannot provide. It is the last job holding the self-hosted Mac open. Adds the same suite as a second `suite` choice, with the emulator stack on an `ubuntu-latest` runner reached over Tailscale: | job | runner | |---|---| | `trezor-stack` | ubuntu — bitcoind, electrs and Trezor User Env; holds until the tests finish | | `trezor-emu-remote` | macos-latest — the same xcodebuild invocation | The suite addresses Bridge, the User Env controller, electrs and bitcoind as 127.0.0.1 from inside the Simulator, across five call sites in BitkitUITests/TrezorBridgeDashboardUITests.swift that are not all configurable. The Mac job relays those six ports onto loopback instead, so the test and the app are unchanged — no Swift and no Info.plist edits. Verified against the pinned trezor-user-env image on Linux: Bridge binds 0.0.0.0 rather than loopback, origin checking is patched out, and a full acquire -> GetFeatures -> release round trip works off-host and through the relay, as does emulator-press-yes over the controller websocket. Nothing existing changes. `trezor-emu` is untouched and still dispatchable. The two new jobs must not depend on each other: `trezor-stack` only finishes once the tests are done, so a dependency either way deadlocks. Co-Authored-By: Claude Opus 5 --- .github/workflows/ai-device-tests.yml | 310 ++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index f94661902..e6dd53744 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -10,6 +10,7 @@ on: type: choice options: - trezor-emu + - trezor-emu-remote simulator_name: description: "iOS Simulator name" required: false @@ -134,3 +135,312 @@ jobs: run: | ./scripts/trezor-emulator stop || true docker compose down || true + + # The emulator stack for trezor-emu-remote, on a runner that can run Docker. + # Must not depend on trezor-emu-remote, nor it on this: this job only finishes + # once the tests are done, so a dependency either way deadlocks. + trezor-stack: + if: inputs.suite == 'trezor-emu-remote' + runs-on: ubuntu-latest + timeout-minutes: 180 + + steps: + - name: Checkout bitkit-docker + uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-docker + ref: main + + - uses: tailscale/github-action@v3 + with: + authkey: ${{ secrets.TS_AUTHKEY }} + hostname: trezor-stack-${{ github.run_id }} + args: --accept-dns=false + + - name: Start regtest and Trezor emulator + run: | + set -euo pipefail + echo "tailnet address: $(tailscale ip -4)" + + # Naming the services: trezor-user-env-mac carries no `profiles` key, + # so a bare `up -d` starts it too and it takes 21325 before the + # host-networked Linux service the helper selects here can bind it. + docker compose up -d bitcoind bitcoinsetup electrs darkhttpd + ./scripts/trezor-emulator start + docker compose ps + + wait_for() { + local what=$1 deadline=$(( SECONDS + 300 )) + until eval "$2"; do + if (( SECONDS >= deadline )); then + echo "::error::timed out waiting for $what" + docker compose logs --no-color --tail=50 + exit 1 + fi + sleep 5 + done + echo "✓ $what" + } + + wait_for "electrs on 60001" 'nc -z 127.0.0.1 60001' + wait_for "bitcoind rpc on 43782" 'nc -z 127.0.0.1 43782' + # Bridge answers on 21325 before it has the emulator, so wait on the + # device rather than on the port. + wait_for "a trezor device on the bridge" \ + 'curl -fsS -m 10 -X POST http://127.0.0.1:21325/enumerate | grep -q "\"path\""' + + - name: Hold the stack up until the tests finish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + deadline=$(( SECONDS + 9000 )) + while (( SECONDS < deadline )); do + pending=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ + --paginate --jq '[.jobs[] | select(.name == "trezor-emu-remote") | select(.status != "completed")] | length' \ + 2>/dev/null || echo 1) + started=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ + --paginate --jq '[.jobs[] | select(.name == "trezor-emu-remote")] | length' \ + 2>/dev/null || echo 0) + echo "trezor-emu-remote: ${started} job(s), ${pending} still running" + if [ "$started" -gt 0 ] && [ "$pending" -eq 0 ]; then + echo "tests finished" + break + fi + sleep 30 + done + + - name: Collect stack diagnostics + if: always() + run: | + mkdir -p ai-device-artifacts/trezor-stack + ./scripts/trezor-emulator status > ai-device-artifacts/trezor-stack/trezor-status.json 2>&1 || true + curl --silent --show-error -X POST http://127.0.0.1:21325/enumerate > ai-device-artifacts/trezor-stack/bridge-enumerate.json 2>&1 || true + docker compose --profile trezor-linux logs --no-color --tail=500 trezor-user-env-linux > ai-device-artifacts/trezor-stack/trezor-user-env.log 2>&1 || true + docker compose logs --no-color > ai-device-artifacts/trezor-stack/docker-compose.log 2>&1 || true + + - name: Upload stack diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ai-device-tests-trezor-stack-${{ github.run_number }} + path: ai-device-artifacts/trezor-stack + if-no-files-found: warn + + - name: Stop emulator services + if: always() + run: | + ./scripts/trezor-emulator stop || true + docker compose down || true + + # Same suite as trezor-emu, on a GitHub-hosted Mac with the emulator stack on + # another runner. Runs alongside it until it has earned replacing it. + trezor-emu-remote: + if: inputs.suite == 'trezor-emu-remote' + runs-on: macos-latest + timeout-minutes: 180 + + steps: + - name: Checkout Bitkit iOS + uses: actions/checkout@v7 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.2" + + - name: Install xcbeautify + run: | + if ! command -v xcbeautify >/dev/null 2>&1; then + brew install xcbeautify + fi + + - name: System information + run: | + sw_vers + xcodebuild -version + + - name: Cache Swift Package Manager + uses: actions/cache@v6 + with: + path: | + ~/Library/Caches/org.swift.swiftpm + ~/Library/org.swift.swiftpm + Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile | xcbeautify + + - uses: tailscale/github-action@v3 + with: + authkey: ${{ secrets.TS_AUTHKEY }} + hostname: trezor-tester-${{ github.run_id }} + # Peers are found via `tailscale status`, so MagicDNS is unused. Leaving + # it on rewrites the resolver, and its macOS setup targets a network + # service named "Ethernet" that hosted runners do not have. + args: --accept-dns=false + + - name: Find the stack runner + run: | + set -euo pipefail + deadline=$(( SECONDS + 1800 )) + while :; do + ip=$(tailscale status --json 2>/dev/null \ + | jq -r --arg h "trezor-stack-${{ github.run_id }}" \ + 'first(.Peer[]? | select(.HostName == $h) | .TailscaleIPs[0]) // empty' \ + || true) + [ -n "$ip" ] && break + if (( SECONDS >= deadline )); then + echo "::error::stack runner never joined the tailnet" + tailscale status || true + exit 1 + fi + sleep 10 + done + echo "STACK_IP=$ip" >> "$GITHUB_ENV" + + for port in 21325 9001 43782 60001 9002 6080; do + until nc -z -w 5 "$ip" "$port" 2>/dev/null; do + if (( SECONDS >= deadline )); then + echo "::error::$ip:$port never became reachable" + tailscale ping -c 3 "$ip" || true + exit 1 + fi + sleep 10 + done + done + echo "✓ stack reachable at $ip" + + - name: Forward the stack onto loopback + run: | + set -euo pipefail + # The suite reaches Bridge, the User Env controller, electrs and + # bitcoind as 127.0.0.1 from inside the Simulator, across five call + # sites in BitkitUITests/TrezorBridgeDashboardUITests.swift that are + # not all configurable. Relaying those ports keeps the test unchanged. + cat > /tmp/forward-stack.py <<'PY' + import asyncio + import sys + + HOST = sys.argv[1] + PORTS = [int(port) for port in sys.argv[2:]] + + + async def pipe(reader, writer): + try: + while (chunk := await reader.read(65536)): + writer.write(chunk) + await writer.drain() + except Exception: + pass + finally: + writer.close() + + + def forward(port): + async def handle(local_reader, local_writer): + remote_reader, remote_writer = await asyncio.open_connection(HOST, port) + await asyncio.gather( + pipe(local_reader, remote_writer), + pipe(remote_reader, local_writer), + ) + + return handle + + + async def main(): + servers = [ + await asyncio.start_server(forward(port), "127.0.0.1", port) + for port in PORTS + ] + print(f"forwarding {PORTS} to {HOST}", flush=True) + await asyncio.gather(*(server.serve_forever() for server in servers)) + + + asyncio.run(main()) + PY + + nohup python3 /tmp/forward-stack.py "$STACK_IP" \ + 21325 9001 43782 60001 9002 6080 > /tmp/forward-stack.log 2>&1 & + + # End to end, not just a local accept: the relay listens before it has + # dialled anything, so a port check against it passes either way. + deadline=$(( SECONDS + 120 )) + until curl -fsS -m 10 -X POST http://127.0.0.1:21325/enumerate | grep -q '"path"'; do + if (( SECONDS >= deadline )); then + echo "::error::bridge did not answer through the relay" + cat /tmp/forward-stack.log || true + exit 1 + fi + sleep 5 + done + curl -fsS -X POST http://127.0.0.1:21325/enumerate + echo "✓ bridge reachable on 127.0.0.1:21325" + + - name: Boot simulator + env: + SIMULATOR_NAME: ${{ inputs.simulator_name }} + run: | + if ! xcodebuild -showsdks | grep -q "iOS Simulator"; then + xcodebuild -downloadPlatform iOS + fi + + xcrun simctl shutdown all || true + xcrun simctl erase "$SIMULATOR_NAME" || true + defaults write com.apple.iphonesimulator DisableAllNotifications -bool true + xcrun simctl boot "$SIMULATOR_NAME" || true + xcrun simctl bootstatus "$SIMULATOR_NAME" -b + # First boot on a cold runner keeps working after bootstatus returns, + # and XCUITest attaching into that loses the first launch. + open -a Simulator + sleep 30 + + - name: Run Trezor emulator UI tests + env: + TEST_TREZOR_EMU: "1" + TEST_TREZOR_RESET_STATE: "1" + TREZOR_BRIDGE: "true" + TREZOR_BRIDGE_URL: "http://127.0.0.1:21325" + TREZOR_ELECTRUM_URL: "tcp://127.0.0.1:60001" + E2E: "true" + E2E_BACKEND: "local" + E2E_NETWORK: "regtest" + GEO: "false" + SIMULATOR_NAME: ${{ inputs.simulator_name }} + SIMULATOR_OS: ${{ inputs.simulator_os }} + run: | + mkdir -p TestResults + set -o pipefail + xcodebuild test \ + -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme BitkitAITests \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$SIMULATOR_OS" \ + -derivedDataPath DerivedData \ + -resultBundlePath TestResults/TrezorBridgeDashboardUITests.xcresult \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ + -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ + -parallel-testing-enabled NO \ + -allowProvisioningUpdates \ + | xcbeautify + + - name: Collect diagnostics + if: always() + run: | + mkdir -p ai-device-artifacts/trezor-remote + xcrun simctl io booted screenshot ai-device-artifacts/trezor-remote/simulator.png || true + if [ -d TestResults ]; then + cp -R TestResults ai-device-artifacts/trezor-remote/ || true + fi + curl --silent --show-error -X POST http://127.0.0.1:21325/enumerate > ai-device-artifacts/trezor-remote/bridge-enumerate.json 2>&1 || true + cp /tmp/forward-stack.log ai-device-artifacts/trezor-remote/ || true + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ai-device-tests-trezor-remote-${{ github.run_number }} + path: ai-device-artifacts/trezor-remote + if-no-files-found: warn From a0d13e031b9563f66fce0a96208eacdf30c07be8 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Fri, 28 Aug 2026 17:48:17 +0200 Subject: [PATCH 2/3] test: report the assertion behind a failed trezor-emu-remote run xcbeautify condenses a failed test to one line, which for an assertion carrying no message says only that it failed. Read the file, line and attached accessibility dump back out of the result bundle instead. Co-Authored-By: Claude Opus 5 --- .github/workflows/ai-device-tests.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index e6dd53744..1cc63986e 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -426,6 +426,30 @@ jobs: -allowProvisioningUpdates \ | xcbeautify + # xcbeautify prints one condensed line per failed test, which for an + # assertion carrying no message says only that it failed. The result + # bundle has the file and line, and the accessibility dump the UI tests + # attach to their messages. + - name: Report test failures + if: failure() + run: | + set -uo pipefail + bundle=TestResults/TrezorBridgeDashboardUITests.xcresult + if [ ! -d "$bundle" ]; then + echo "no result bundle at $bundle" + exit 0 + fi + + echo "=== failures ===" + xcrun xcresulttool get test-results tests --path "$bundle" --format json 2>/dev/null \ + | jq -r '.. | objects | select(.nodeType? == "Failure Message") | .name' \ + || echo "could not read failure messages" + + echo "=== summary ===" + xcrun xcresulttool get test-results summary --path "$bundle" --format json 2>/dev/null \ + | jq -r '.testFailures[]? | "\(.testName): \(.failureText)"' \ + || echo "could not read summary" + - name: Collect diagnostics if: always() run: | From 6e52b14fbbf3c1192c6782482ce8143a0dc0f720 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Fri, 28 Aug 2026 17:55:17 +0200 Subject: [PATCH 3/3] test: give the trezor-emu-remote regtest wallet spendable coins A coinbase matures after 100 confirmations and the compose setup mines a single block, so the wallet holds nothing spendable and the suite cannot fund a Trezor address. A developer machine carries blocks over in the chain volume between runs, which hides it; a runner always starts at height 1. Verified against the same compose: fresh stack is height 1 / balance 0, and sendtoaddress fails; after mining 101 the balance is 100 and the suite's own 0.001 funding call returns a txid. Co-Authored-By: Claude Opus 5 --- .github/workflows/ai-device-tests.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index 1cc63986e..69881a0d2 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -189,6 +189,26 @@ jobs: wait_for "a trezor device on the bridge" \ 'curl -fsS -m 10 -X POST http://127.0.0.1:21325/enumerate | grep -q "\"path\""' + # A coinbase matures after 100 confirmations and the compose setup mines a + # single block, so nothing is spendable and the suite cannot fund a Trezor + # address. On a developer machine the chain volume carries blocks over + # between runs, which hides this; a runner always starts at height 1. + - name: Fund the regtest wallet + run: | + set -euo pipefail + rpc() { + curl -fsS --user polaruser:polarpass -H 'content-type: application/json' \ + --data "{\"jsonrpc\":\"1.0\",\"id\":\"ci\",\"method\":\"$1\",\"params\":$2}" \ + http://127.0.0.1:43782/ + } + + address=$(rpc getnewaddress '[]' | jq -r '.result') + rpc generatetoaddress "[101, \"$address\"]" > /dev/null + + balance=$(rpc getbalance '[]' | jq -r '.result') + echo "spendable balance: $balance" + echo "$balance" | jq -e '. > 0' > /dev/null + - name: Hold the stack up until the tests finish env: GH_TOKEN: ${{ github.token }}