From 5616311b570b8d33f7a372667d9975af3cbe4a0c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 21 May 2026 20:07:38 +0200 Subject: [PATCH 1/4] test: add ai trezor emulator device tests --- .github/workflows/ai-device-tests.yml | 134 ++++++ .../xcschemes/BitkitAITests.xcscheme | 91 ++++ Bitkit/AppScene.swift | 13 +- .../Components/Trezor/TrezorDeviceRow.swift | 2 + .../Trezor/TrezorExpandableSection.swift | 2 + .../Trezor/TrezorPairingCodeInput.swift | 2 + Bitkit/Components/Trezor/TrezorPinPad.swift | 9 +- .../Components/Trezor/TrezorStatusBadge.swift | 3 + Bitkit/Constants/Env.swift | 4 + .../ViewModels/Trezor/TrezorViewModel.swift | 23 + Bitkit/Views/Trezor/TrezorAddressView.swift | 14 +- .../Trezor/TrezorBalanceLookupView.swift | 21 +- Bitkit/Views/Trezor/TrezorConnectedView.swift | 10 + .../Trezor/TrezorDeviceFeaturesView.swift | 5 + .../Views/Trezor/TrezorDeviceListView.swift | 7 + Bitkit/Views/Trezor/TrezorPublicKeyView.swift | 11 +- Bitkit/Views/Trezor/TrezorRootView.swift | 56 +++ .../Trezor/TrezorSendTransactionView.swift | 18 +- .../Views/Trezor/TrezorSignMessageView.swift | 14 +- .../Trezor/TrezorTransactionDetailView.swift | 13 + .../Trezor/TrezorTransactionHistoryView.swift | 8 + .../TrezorBridgeDashboardUITests.swift | 438 ++++++++++++++++++ Docs/AI_DEVICE_TESTS.md | 34 ++ 23 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ai-device-tests.yml create mode 100644 Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme create mode 100644 BitkitUITests/TrezorBridgeDashboardUITests.swift create mode 100644 Docs/AI_DEVICE_TESTS.md diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml new file mode 100644 index 000000000..5d6eaafe7 --- /dev/null +++ b/.github/workflows/ai-device-tests.yml @@ -0,0 +1,134 @@ +name: ai-device-tests + +on: + workflow_dispatch: + inputs: + suite: + description: "AI device test suite to run" + required: true + default: "trezor-emu" + type: choice + options: + - trezor-emu + simulator_name: + description: "iOS Simulator name" + required: false + default: "iPhone 17" + simulator_os: + description: "iOS Simulator OS version" + required: false + default: "26.2" + +env: + TERM: xterm-256color + FORCE_COLOR: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.suite }} + cancel-in-progress: true + +jobs: + trezor-emu: + if: inputs.suite == 'trezor-emu' + name: Trezor emulator dashboard + runs-on: [self-hosted, macOS] + timeout-minutes: 90 + + steps: + - name: Checkout Bitkit iOS + uses: actions/checkout@v6 + + - name: Checkout bitkit-docker + uses: actions/checkout@v6 + with: + repository: synonymdev/bitkit-docker + ref: main + path: bitkit-docker + + - 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 + docker version + docker compose version + + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile | xcbeautify + + - name: Start regtest and Trezor emulator + working-directory: bitkit-docker + run: | + docker compose up -d + ./scripts/trezor-emulator start + ./scripts/trezor-emulator status + + - name: Boot simulator + env: + SIMULATOR_NAME: ${{ inputs.simulator_name }} + run: | + xcrun simctl boot "$SIMULATOR_NAME" || true + + - name: Run Trezor emulator UI tests + env: + TEST_TREZOR_EMU: "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' \ + -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ + -parallel-testing-enabled NO \ + | xcbeautify + + - name: Collect diagnostics + if: always() + run: | + mkdir -p ai-device-artifacts/trezor + xcrun simctl io booted screenshot ai-device-artifacts/trezor/simulator.png || true + if [ -d TestResults ]; then + cp -R TestResults ai-device-artifacts/trezor/ || true + fi + ( + cd bitkit-docker + ./scripts/trezor-emulator status > ../ai-device-artifacts/trezor/trezor-status.json 2>&1 || true + docker compose logs --no-color --tail=500 trezor-user-env-mac > ../ai-device-artifacts/trezor/trezor-user-env.log 2>&1 || true + docker compose logs --no-color > ../ai-device-artifacts/trezor/docker-compose.log 2>&1 || true + curl --silent --show-error -X POST http://127.0.0.1:21325/enumerate > ../ai-device-artifacts/trezor/bridge-enumerate.json 2>&1 || true + ) + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: ai-device-tests-trezor-${{ github.run_number }} + path: ai-device-artifacts/trezor + if-no-files-found: warn + + - name: Stop emulator services + if: always() + working-directory: bitkit-docker + run: | + ./scripts/trezor-emulator stop || true + docker compose down || true diff --git a/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme new file mode 100644 index 000000000..1db566a33 --- /dev/null +++ b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 2a862871c..da9433345 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -194,7 +194,9 @@ struct AppScene: View { private var mainContent: some View { ZStack { - if migrations.isShowingMigrationLoading { + if Env.isTrezorEmulatorTesting { + trezorEmulatorTestContent + } else if migrations.isShowingMigrationLoading { migrationLoadingContent } else if showRecoveryScreen { RecoveryRouter() @@ -205,7 +207,7 @@ struct AppScene: View { walletContent } - if !removeSplash && !session.skipSplashOnce { + if !Env.isTrezorEmulatorTesting, !removeSplash, !session.skipSplashOnce { SplashView() .opacity(hideSplash ? 0 : 1) } @@ -267,6 +269,13 @@ struct AppScene: View { } } + private var trezorEmulatorTestContent: some View { + NavigationStack { + TrezorRootView() + } + .accentColor(.white) + } + @ViewBuilder private var existingWalletContent: some View { if walletIsInitializing == true { diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift index 1b5fec686..c1e81c684 100644 --- a/Bitkit/Components/Trezor/TrezorDeviceRow.swift +++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift @@ -55,6 +55,7 @@ struct TrezorDeviceRow: View { } .buttonStyle(.plain) .disabled(isConnecting) + .accessibilityIdentifier(device.path.hasPrefix("bridge:") ? "TrezorDevice-bridge" : "TrezorDevice-\(device.path)") } private var displayName: String { @@ -150,6 +151,7 @@ struct KnownDeviceRow: View { .padding(16) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityIdentifier("TrezorKnownDevice-\(device.id)") } } diff --git a/Bitkit/Components/Trezor/TrezorExpandableSection.swift b/Bitkit/Components/Trezor/TrezorExpandableSection.swift index 20e64f39e..0e618429a 100644 --- a/Bitkit/Components/Trezor/TrezorExpandableSection.swift +++ b/Bitkit/Components/Trezor/TrezorExpandableSection.swift @@ -6,6 +6,7 @@ struct TrezorExpandableSection: View { let title: String let icon: String let description: String + var accessibilityIdentifier: String? @Binding var isExpanded: Bool @ViewBuilder let content: () -> Content @@ -44,6 +45,7 @@ struct TrezorExpandableSection: View { .animation(.easeInOut(duration: 0.25), value: isExpanded) } } + .accessibilityIdentifier(accessibilityIdentifier ?? "TrezorSection-\(title.replacingOccurrences(of: " ", with: ""))") // Expandable content if isExpanded { diff --git a/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift b/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift index 5b6f4777d..3938622d6 100644 --- a/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift +++ b/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift @@ -30,6 +30,7 @@ struct TrezorPairingCodeInput: View { .focused($isFocused) .frame(width: 1, height: 1) .opacity(0.01) // Nearly invisible but still functional + .accessibilityIdentifier("TrezorPairingCodeInput") .onChange(of: code) { newValue in // Filter to only digits and limit length let filtered = newValue.filter(\.isNumber) @@ -48,6 +49,7 @@ struct TrezorPairingCodeInput: View { try? await Task.sleep(nanoseconds: 200_000_000) isFocused = true } + .accessibilityIdentifier("TrezorPairingCodeDisplay") } /// Get digit at specific index, or nil if not entered yet diff --git a/Bitkit/Components/Trezor/TrezorPinPad.swift b/Bitkit/Components/Trezor/TrezorPinPad.swift index de7423fda..17952b70e 100644 --- a/Bitkit/Components/Trezor/TrezorPinPad.swift +++ b/Bitkit/Components/Trezor/TrezorPinPad.swift @@ -9,8 +9,8 @@ struct TrezorPinPad: View { /// Maximum PIN length var maxLength: Int = 9 - // PIN pad layout (positions map to device keypad) - // The Trezor shows scrambled numbers, we show only position dots + /// PIN pad layout (positions map to device keypad) + /// The Trezor shows scrambled numbers, we show only position dots private let positions = [ ["7", "8", "9"], ["4", "5", "6"], @@ -54,22 +54,22 @@ struct TrezorPinPad: View { } .disabled(pin.isEmpty) .opacity(pin.isEmpty ? 0.3 : 1.0) + .accessibilityIdentifier("TrezorPinDelete") } .padding(.top, 8) } .padding(16) + .accessibilityIdentifier("TrezorPinPad") } private func handleDigitTap(_ position: String) { guard pin.count < maxLength else { return } pin += position - } private func handleDelete() { guard !pin.isEmpty else { return } pin.removeLast() - } } @@ -91,6 +91,7 @@ private struct PinButton: View { ) } .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("TrezorPinPosition-\(position)") } } diff --git a/Bitkit/Components/Trezor/TrezorStatusBadge.swift b/Bitkit/Components/Trezor/TrezorStatusBadge.swift index c928ef3f8..fa96dcd90 100644 --- a/Bitkit/Components/Trezor/TrezorStatusBadge.swift +++ b/Bitkit/Components/Trezor/TrezorStatusBadge.swift @@ -63,11 +63,13 @@ struct TrezorConfirmOnDeviceOverlay: View { .foregroundColor(.white.opacity(0.6)) } .padding(.top, 8) + .accessibilityIdentifier("TrezorConfirmOnDeviceCancel") } } .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.9)) + .accessibilityIdentifier("TrezorConfirmOnDeviceOverlay") } } @@ -118,6 +120,7 @@ struct TrezorErrorBanner: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("TrezorErrorBanner") } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 63f2bb042..e07e30191 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -166,6 +166,10 @@ enum Env { return configValue("TREZOR_ELECTRUM_URL") } + static var isTrezorEmulatorTesting: Bool { + (isDebug || isE2E) && boolConfigValue("TEST_TREZOR_EMU") + } + static var appStorageUrl: URL { // App group so files can be shared with extensions guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else { diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index b816dc4b0..c231f53d9 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -1326,4 +1326,27 @@ class TrezorViewModel { trezorLog("Failed to clear credentials: \(error)", level: "error") } } + + // MARK: - AI Test Hooks + + func testShowPinPrompt() { + guard Env.isTrezorEmulatorTesting else { return } + showPinEntry = true + } + + func testShowPassphrasePrompt() { + guard Env.isTrezorEmulatorTesting else { return } + showPassphraseEntry = true + } + + func testShowPairingCodePrompt() { + guard Env.isTrezorEmulatorTesting else { return } + showPairingCode = true + } + + func testShowConfirmOnDevicePrompt() { + guard Env.isTrezorEmulatorTesting else { return } + confirmMessage = "Confirm test action on your Trezor" + showConfirmOnDevice = true + } } diff --git a/Bitkit/Views/Trezor/TrezorAddressView.swift b/Bitkit/Views/Trezor/TrezorAddressView.swift index 5bb2c4843..cc1322184 100644 --- a/Bitkit/Views/Trezor/TrezorAddressView.swift +++ b/Bitkit/Views/Trezor/TrezorAddressView.swift @@ -96,6 +96,7 @@ private struct AddressTypeSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorAddressType") } } } @@ -125,6 +126,7 @@ private struct DerivationPathSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorDerivationPath") // Address index stepper HStack { @@ -140,16 +142,19 @@ private struct DerivationPathSection: View { .foregroundColor(trezor.addressIndex == 0 ? .white.opacity(0.2) : .white.opacity(0.6)) } .disabled(trezor.addressIndex == 0) + .accessibilityIdentifier("TrezorAddressIndexDecrement") Button(action: { trezor.incrementAddressIndex() }) { Image(systemName: "plus.circle") .font(.system(size: 20)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorAddressIndexIncrement") } .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorAddressIndex") Button(action: { trezor.derivationPath = selectedScriptType.defaultPath(coinType: trezor.coinTypeComponent) @@ -159,6 +164,7 @@ private struct DerivationPathSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorAddressDefaultPath") } .toolbar { ToolbarItemGroup(placement: .keyboard) { @@ -193,6 +199,7 @@ private struct VerifyOnDeviceSection: View { Toggle("", isOn: $trezor.showAddressOnDevice) .labelsHidden() .tint(.green) + .accessibilityIdentifier("TrezorShowAddressOnDevice") } .padding(16) .background(Color.white.opacity(0.05)) @@ -228,6 +235,7 @@ private struct GenerateButtonSection: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } .disabled(trezor.isOperating) + .accessibilityIdentifier("TrezorGenerateAddress") } } @@ -257,6 +265,7 @@ private struct AddressResultSection: View { .background(Color.white.opacity(0.15)) .clipShape(Capsule()) } + .accessibilityIdentifier("TrezorAddressNextIndex") } if let error = trezor.error { @@ -282,6 +291,7 @@ private struct GeneratedAddressCard: View { .frame(maxWidth: .infinity) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityIdentifier("TrezorGeneratedAddressCard") } } @@ -313,6 +323,7 @@ private struct QRCodeView: View { .task(id: content) { qrImage = Self.generateQRCode(from: content) } + .accessibilityIdentifier("TrezorGeneratedAddressQr") } private static func generateQRCode(from string: String) -> UIImage? { @@ -351,6 +362,7 @@ private struct AddressText: View { .foregroundColor(.white) .multilineTextAlignment(.center) .padding(.horizontal, 16) + .accessibilityIdentifier("TrezorGeneratedAddress") } } @@ -371,13 +383,13 @@ private struct CopyButton: View { .background(Color.white.opacity(0.15)) .clipShape(Capsule()) } + .accessibilityIdentifier("TrezorCopyAddress") } private func copyAddress() { UIPasteboard.general.string = address copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } diff --git a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift index 9f02dc107..5e2860a8a 100644 --- a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift +++ b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift @@ -145,6 +145,7 @@ private struct InputSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorLookupInput") .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() @@ -167,6 +168,7 @@ private struct InputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorLookupPaste") if !input.isEmpty { Button(action: { input = "" }) { @@ -177,6 +179,7 @@ private struct InputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorLookupClear") } } } @@ -245,6 +248,7 @@ private struct LookupButton: View { } .disabled(isDisabled || isLoading) .opacity(isDisabled ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorLookupButton") } } @@ -270,14 +274,19 @@ private struct AccountResultSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorAccountResult") } private func accountTypeLabel(_ type: AccountType) -> String { switch type { - case .legacy: "Legacy (BIP44 / P2PKH)" - case .wrappedSegwit: "Wrapped SegWit (BIP49 / P2SH-P2WPKH)" - case .nativeSegwit: "Native SegWit (BIP84 / P2WPKH)" - case .taproot: "Taproot (BIP86 / P2TR)" + case .legacy: + return "Legacy (BIP44 / P2PKH)" + case .wrappedSegwit: + return "Wrapped SegWit (BIP49 / P2SH-P2WPKH)" + case .nativeSegwit: + return "Native SegWit (BIP84 / P2WPKH)" + case .taproot: + return "Taproot (BIP86 / P2TR)" } } } @@ -304,6 +313,7 @@ private struct AddressResultSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorAddressLookupResult") } } @@ -327,6 +337,7 @@ private struct ResultRow: View { Spacer() } + .accessibilityIdentifier("TrezorResultRow-\(label.replacingOccurrences(of: " ", with: ""))") } } @@ -348,6 +359,7 @@ private struct UTXOListSection: View { } } } + .accessibilityIdentifier("TrezorUtxoList") } } } @@ -432,6 +444,7 @@ private struct UTXORow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorUtxoRow") } private var truncatedTxid: String { diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index f66d84f59..672aa621a 100644 --- a/Bitkit/Views/Trezor/TrezorConnectedView.swift +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -28,6 +28,7 @@ struct TrezorConnectedView: View { title: "Get Address", icon: "qrcode", description: "Generate a receiving address", + accessibilityIdentifier: "TrezorSection-Address", isExpanded: $isAddressExpanded ) { TrezorAddressContent() @@ -37,6 +38,7 @@ struct TrezorConnectedView: View { title: "Sign Message", icon: "signature", description: "Sign a message with your Trezor", + accessibilityIdentifier: "TrezorSection-SignMessage", isExpanded: $isSignMessageExpanded ) { TrezorSignMessageContent() @@ -46,6 +48,7 @@ struct TrezorConnectedView: View { title: "Public Key", icon: "key", description: "Get xpub and public key", + accessibilityIdentifier: "TrezorSection-PublicKey", isExpanded: $isPublicKeyExpanded ) { TrezorPublicKeyContent() @@ -55,6 +58,7 @@ struct TrezorConnectedView: View { title: "Balance Lookup", icon: "magnifyingglass", description: "Check balance & UTXOs for any address or xpub", + accessibilityIdentifier: "TrezorSection-BalanceLookup", isExpanded: $isBalanceLookupExpanded ) { TrezorBalanceLookupContent() @@ -64,6 +68,7 @@ struct TrezorConnectedView: View { title: "Transaction History", icon: "list.bullet.rectangle", description: "Get transaction history for any xpub", + accessibilityIdentifier: "TrezorSection-TxHistory", isExpanded: $isTxHistoryExpanded ) { TrezorTransactionHistoryContent() @@ -73,6 +78,7 @@ struct TrezorConnectedView: View { title: "Transaction Detail", icon: "doc.text.magnifyingglass", description: "Get detailed info for a specific transaction", + accessibilityIdentifier: "TrezorSection-TxDetail", isExpanded: $isTxDetailExpanded ) { TrezorTransactionDetailContent() @@ -82,6 +88,7 @@ struct TrezorConnectedView: View { title: "Device Info", icon: "info.circle", description: "View device details and features", + accessibilityIdentifier: "TrezorSection-DeviceInfo", isExpanded: $isDeviceInfoExpanded ) { TrezorDeviceFeaturesContent() @@ -105,6 +112,7 @@ struct TrezorConnectedView: View { .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorDisconnectButton") } .padding(16) .contentShape(Rectangle()) @@ -116,6 +124,7 @@ struct TrezorConnectedView: View { .background(Color.black) .navigationTitle("Trezor") .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("TrezorConnectedView") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { TrezorStatusBadge( @@ -171,6 +180,7 @@ private struct DeviceInfoCard: View { .frame(maxWidth: .infinity) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityIdentifier("TrezorDeviceInfoCard") } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift index 0fc6e8aa0..346c1a442 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift @@ -16,6 +16,7 @@ struct TrezorDeviceFeaturesContent: View { NoFeaturesView() } } + .accessibilityIdentifier("TrezorDeviceFeatures") } } @@ -159,6 +160,7 @@ private struct ActionsSection: View { .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) } + .accessibilityIdentifier("TrezorClearCredentials") Text("This will require re-pairing via Bluetooth on next connection") .font(.system(size: 12)) @@ -182,6 +184,7 @@ private struct NoFeaturesView: View { .foregroundColor(.white.opacity(0.6)) } .padding(48) + .accessibilityIdentifier("TrezorNoFeatures") } } @@ -205,6 +208,7 @@ private struct InfoSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorDeviceInfo-\(title.replacingOccurrences(of: " ", with: ""))") } } @@ -227,6 +231,7 @@ private struct InfoRow: View { .font(.system(size: 14, design: isMonospaced ? .monospaced : .default)) .foregroundColor(.white) .lineLimit(1) + .accessibilityIdentifier("TrezorDeviceInfoValue-\(label.replacingOccurrences(of: " ", with: ""))") } } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 807152db0..495c0b711 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -108,12 +108,14 @@ struct TrezorDeviceListView: View { .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorScanButton") .padding(16) } } .background(Color.black) .navigationTitle("Connect Trezor") .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("TrezorDeviceList") .task { trezor.loadKnownDevices() @@ -184,6 +186,7 @@ private struct AutoReconnectIndicator: View { .foregroundColor(.white.opacity(0.6)) } .padding(16) + .accessibilityIdentifier("TrezorAutoReconnectIndicator") } } @@ -209,6 +212,7 @@ private struct BluetoothStatusCard: View { .frame(maxWidth: .infinity) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .accessibilityIdentifier("TrezorBluetoothStatus") } private var statusTitle: String { @@ -255,6 +259,7 @@ private struct ScanningIndicator: View { .multilineTextAlignment(.center) } .padding(32) + .accessibilityIdentifier("TrezorScanningIndicator") } } @@ -277,6 +282,7 @@ private struct TrezorEmptyStateView: View { } } .padding(32) + .accessibilityIdentifier("TrezorEmptyState") } } @@ -297,6 +303,7 @@ private struct ErrorCard: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("TrezorDeviceListError") } } diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift index 0842b3068..cc6e7b34b 100644 --- a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift +++ b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift @@ -19,6 +19,7 @@ struct TrezorPublicKeyContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorPublicKeyPath") } // Show on device toggle @@ -37,6 +38,7 @@ struct TrezorPublicKeyContent: View { Toggle("", isOn: $trezor.showPublicKeyOnDevice) .labelsHidden() .tint(.green) + .accessibilityIdentifier("TrezorShowPublicKeyOnDevice") } .padding(16) .background(Color.white.opacity(0.05)) @@ -65,14 +67,15 @@ struct TrezorPublicKeyContent: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } .disabled(trezor.isOperating) + .accessibilityIdentifier("TrezorPublicKeyGet") // Results if let xpub = trezor.xpub { - CopyableField(label: "Extended Public Key (xpub)", value: xpub) + CopyableField(label: "Extended Public Key (xpub)", value: xpub, accessibilityIdentifier: "TrezorXpub") } if let pubKey = trezor.publicKeyHex { - CopyableField(label: "Compressed Public Key", value: pubKey) + CopyableField(label: "Compressed Public Key", value: pubKey, accessibilityIdentifier: "TrezorPublicKeyHex") } // Error @@ -102,6 +105,7 @@ struct TrezorPublicKeyView: View { private struct CopyableField: View { let label: String let value: String + let accessibilityIdentifier: String @State private var copied = false var body: some View { @@ -117,6 +121,7 @@ private struct CopyableField: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier(accessibilityIdentifier) Button(action: { UIPasteboard.general.string = value @@ -133,10 +138,12 @@ private struct CopyableField: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("\(accessibilityIdentifier)Copy") } .padding(16) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("\(accessibilityIdentifier)Card") } } diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index d7685c3f3..b93e5f439 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -10,6 +10,10 @@ struct TrezorRootView: View { VStack(spacing: 0) { NetworkSelectorRow() + if Env.isTrezorEmulatorTesting { + TrezorEmulatorTestHooksView() + } + ZStack(alignment: .bottom) { TrezorContentSwitcher() .frame(maxHeight: .infinity) @@ -18,6 +22,7 @@ struct TrezorRootView: View { TrezorDebugLogWrapper() } } + .accessibilityIdentifier("TrezorRoot") .modifier(TrezorDialogsModifier()) } } @@ -119,6 +124,7 @@ private struct NetworkSelectorRow: View { .background(trezor.selectedNetwork == network ? Color.white.opacity(0.2) : Color.white.opacity(0.05)) .clipShape(Capsule()) } + .accessibilityIdentifier("TrezorNetwork-\(label)") } } } @@ -171,6 +177,7 @@ struct TrezorPinEntrySheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPinCancel") Button(action: { trezor.submitPin(pin) @@ -186,6 +193,7 @@ struct TrezorPinEntrySheet: View { } .disabled(pin.isEmpty) .opacity(pin.isEmpty ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorPinConfirm") } .padding(.horizontal, 16) } @@ -193,6 +201,7 @@ struct TrezorPinEntrySheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .accessibilityIdentifier("TrezorPinSheet") } } @@ -242,6 +251,7 @@ struct TrezorPairingCodeSheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPairingCancel") Button(action: { guard !hasSubmitted else { return } @@ -259,6 +269,7 @@ struct TrezorPairingCodeSheet: View { } .disabled(code.count < digitCount || hasSubmitted) .opacity(code.count < digitCount || hasSubmitted ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorPairingConfirm") } .padding(.horizontal, 16) } @@ -266,6 +277,7 @@ struct TrezorPairingCodeSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .accessibilityIdentifier("TrezorPairingSheet") .onChange(of: code) { newValue in if newValue.count == digitCount { guard !hasSubmitted else { return } @@ -364,6 +376,7 @@ struct TrezorPassphraseSheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPassphraseCancel") Button(action: { trezor.submitPassphrase(passphrase) @@ -379,6 +392,7 @@ struct TrezorPassphraseSheet: View { } .disabled(!isValid) .opacity(isValid ? 1.0 : 0.5) + .accessibilityIdentifier("TrezorPassphraseConfirm") } .padding(.horizontal, 16) } @@ -386,6 +400,7 @@ struct TrezorPassphraseSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .accessibilityIdentifier("TrezorPassphraseSheet") .task { focusedField = .passphrase } @@ -411,6 +426,43 @@ private struct SecureInputField: View { .padding(16) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("TrezorSecureInput-\(placeholder.replacingOccurrences(of: " ", with: ""))") + } +} + +// MARK: - AI Test Hooks + +private struct TrezorEmulatorTestHooksView: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + HStack(spacing: 8) { + Button("PIN") { + trezor.testShowPinPrompt() + } + .accessibilityIdentifier("TrezorTestHook-Pin") + + Button("Passphrase") { + trezor.testShowPassphrasePrompt() + } + .accessibilityIdentifier("TrezorTestHook-Passphrase") + + Button("Pair") { + trezor.testShowPairingCodePrompt() + } + .accessibilityIdentifier("TrezorTestHook-Pairing") + + Button("Confirm") { + trezor.testShowConfirmOnDevicePrompt() + } + .accessibilityIdentifier("TrezorTestHook-Confirm") + } + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.04)) + .accessibilityIdentifier("TrezorTestHooks") } } @@ -449,6 +501,7 @@ struct TrezorDebugLogPanel: View { .padding(.vertical, 10) } .background(Color.white.opacity(0.05)) + .accessibilityIdentifier("TrezorDebugLogToggle") // Expanded content if isExpanded { @@ -466,6 +519,7 @@ struct TrezorDebugLogPanel: View { .font(.system(size: 11)) .foregroundColor(.white.opacity(0.5)) } + .accessibilityIdentifier("TrezorDebugLogCopy") Button(action: { debugLog.clear() }) { HStack(spacing: 4) { @@ -475,6 +529,7 @@ struct TrezorDebugLogPanel: View { .font(.system(size: 11)) .foregroundColor(.white.opacity(0.5)) } + .accessibilityIdentifier("TrezorDebugLogClear") Spacer() } @@ -495,6 +550,7 @@ struct TrezorDebugLogPanel: View { .padding(.horizontal, 16) } .frame(maxHeight: 300) + .accessibilityIdentifier("TrezorDebugLogEntries") .onChange(of: debugLog.entries.count) { _ in if let lastIndex = debugLog.entries.indices.last { proxy.scrollTo(lastIndex, anchor: .bottom) diff --git a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift index c654ebbc4..e8727f819 100644 --- a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift +++ b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift @@ -74,6 +74,7 @@ struct SendTransactionSection: View { TrezorErrorBanner(message: sendError) } } + .accessibilityIdentifier("TrezorSendSection") } } @@ -113,6 +114,7 @@ private struct ComposeFormView: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorSendAddress") } // Amount + MAX toggle @@ -131,6 +133,7 @@ private struct ComposeFormView: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorSendAmount") Button(action: onToggleSendMax) { Text("MAX") @@ -141,6 +144,7 @@ private struct ComposeFormView: View { .background((isSendMax ? Color.blue : Color.white.opacity(0.3)).opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 6)) } + .accessibilityIdentifier("TrezorSendMax") } } @@ -158,6 +162,7 @@ private struct ComposeFormView: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorSendFeeRate") } // Coin selection strategy @@ -193,6 +198,7 @@ private struct ComposeFormView: View { } .disabled(isDisabled || isComposing) .opacity(isDisabled || isComposing ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorComposeButton") } .toolbar { ToolbarItemGroup(placement: .keyboard) { @@ -232,6 +238,7 @@ private struct CoinSelectionPicker: View { .background(color.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 6)) } + .accessibilityIdentifier("TrezorCoinSelection-\(label.replacingOccurrences(of: " ", with: ""))") } } } @@ -277,6 +284,7 @@ private struct ReviewSectionView: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) } + .accessibilityIdentifier("TrezorComposeBack") Button(action: onSign) { HStack(spacing: 6) { @@ -295,6 +303,7 @@ private struct ReviewSectionView: View { } .disabled(isSigning || !isDeviceConnected) .opacity(isSigning || !isDeviceConnected ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorSignTxButton") } if !isDeviceConnected { @@ -303,6 +312,7 @@ private struct ReviewSectionView: View { .foregroundColor(.white.opacity(0.4)) } } + .accessibilityIdentifier("TrezorComposeReview") } } } @@ -345,6 +355,7 @@ private struct PSBTPreview: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorPsbtPreview") } } @@ -390,7 +401,7 @@ private struct SignedResultSectionView: View { Button(action: { UIPasteboard.general.string = signedTx.serializedTx - + copiedRawTx = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copiedRawTx = false @@ -427,6 +438,7 @@ private struct SignedResultSectionView: View { } .disabled(isBroadcasting) .opacity(isBroadcasting ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorBroadcastButton") } // New transaction button @@ -439,7 +451,9 @@ private struct SignedResultSectionView: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) } + .accessibilityIdentifier("TrezorNewTransaction") } + .accessibilityIdentifier("TrezorSignedTxResult") } } @@ -475,10 +489,12 @@ private struct BroadcastResultCard: View { .foregroundColor(.blue) .textSelection(.enabled) .lineSpacing(2) + .accessibilityIdentifier("TrezorBroadcastTxid") } .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorBroadcastResult") } } diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift index fefba4183..cea048cff 100644 --- a/Bitkit/Views/Trezor/TrezorSignMessageView.swift +++ b/Bitkit/Views/Trezor/TrezorSignMessageView.swift @@ -24,6 +24,7 @@ struct TrezorSignMessageContent: View { } } .pickerStyle(.segmented) + .accessibilityIdentifier("TrezorSignMessageMode") switch selectedTab { case .sign: @@ -77,6 +78,7 @@ private struct SignMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorMessageSigningPath") } // Message input @@ -93,6 +95,7 @@ private struct SignMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorMessageToSign") } // Sign button @@ -120,6 +123,7 @@ private struct SignMessageContent: View { } .disabled(trezor.isOperating || trezor.messageToSign.isEmpty) .opacity(trezor.messageToSign.isEmpty ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorSignMessageButton") // Result display if let signedMessage = trezor.signedMessage { @@ -169,6 +173,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifyAddress") } // Signature input @@ -185,6 +190,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifySignature") } // Message input @@ -201,6 +207,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifyMessage") } // Verify button @@ -232,6 +239,7 @@ private struct VerifyMessageContent: View { } .disabled(trezor.isOperating || !isFormValid) .opacity(isFormValid ? 1.0 : 0.5) + .accessibilityIdentifier("TrezorVerifySignatureButton") // Verification result if let result = verificationResult { @@ -280,6 +288,7 @@ private struct SignedMessageResult: View { Text(response.address) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.white) + .accessibilityIdentifier("TrezorSignedMessageAddress") } // Signature @@ -292,6 +301,7 @@ private struct SignedMessageResult: View { .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white) .lineLimit(3) + .accessibilityIdentifier("TrezorSignature") } // Copy button @@ -299,7 +309,6 @@ private struct SignedMessageResult: View { UIPasteboard.general.string = response.signature copiedSignature = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copiedSignature = false } @@ -311,11 +320,13 @@ private struct SignedMessageResult: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.8)) } + .accessibilityIdentifier("TrezorCopySignature") } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.green.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("TrezorSignedMessageResult") } } @@ -338,6 +349,7 @@ private struct VerificationResultBanner: View { .padding(16) .background((isValid ? Color.green : Color.red).opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier(isValid ? "TrezorSignatureValid" : "TrezorSignatureInvalid") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift index 239b59090..5c8dbfefc 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift @@ -101,6 +101,7 @@ private struct TxDetailInputSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorTxDetailXpub") HStack(spacing: 12) { Button(action: { @@ -115,6 +116,7 @@ private struct TxDetailInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxDetailPasteXpub") if !xpubInput.isEmpty { Button(action: { xpubInput = "" }) { @@ -125,6 +127,7 @@ private struct TxDetailInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxDetailClearXpub") } } } @@ -142,6 +145,7 @@ private struct TxDetailInputSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorTxDetailTxid") HStack(spacing: 12) { Button(action: { @@ -156,6 +160,7 @@ private struct TxDetailInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxDetailPasteTxid") if !txidInput.isEmpty { Button(action: { txidInput = "" }) { @@ -166,6 +171,7 @@ private struct TxDetailInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxDetailClearTxid") } } } @@ -211,6 +217,7 @@ private struct TxDetailLookupButton: View { } .disabled(isDisabled || isLoading) .opacity(isDisabled ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorTxDetailButton") } } @@ -239,6 +246,7 @@ private struct TxDetailOverviewSection: View { .foregroundColor(.white) .textSelection(.enabled) .lineLimit(2) + .accessibilityIdentifier("TrezorTxDetailResultTxid") Spacer() @@ -281,6 +289,7 @@ private struct TxDetailOverviewSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorTxDetailOverview") } private var directionLabel: String { @@ -321,6 +330,7 @@ private struct TxDetailInputsSection: View { } } } + .accessibilityIdentifier("TrezorTxDetailInputs") } } } @@ -395,6 +405,7 @@ private struct TxInputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorTxDetailInputRow") } private var truncatedTxid: String { @@ -424,6 +435,7 @@ private struct TxDetailOutputsSection: View { } } } + .accessibilityIdentifier("TrezorTxDetailOutputs") } } } @@ -477,6 +489,7 @@ private struct TxOutputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorTxDetailOutputRow") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift index 4db528ea8..c6b892741 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift @@ -85,6 +85,7 @@ private struct TxHistoryInputSection: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorTxHistoryInput") .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() @@ -107,6 +108,7 @@ private struct TxHistoryInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxHistoryPaste") if !input.isEmpty { Button(action: { input = "" }) { @@ -117,6 +119,7 @@ private struct TxHistoryInputSection: View { .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } + .accessibilityIdentifier("TrezorTxHistoryClear") } } } @@ -153,6 +156,7 @@ private struct TxHistoryLookupButton: View { } .disabled(isDisabled || isLoading) .opacity(isDisabled ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorTxHistoryButton") } } @@ -185,6 +189,7 @@ private struct TxHistorySummarySection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorTxHistorySummary") } private func accountTypeLabel(_ type: AccountType) -> String { @@ -215,6 +220,7 @@ private struct TxHistoryListSection: View { } } } + .accessibilityIdentifier("TrezorTxHistoryList") } } } @@ -314,6 +320,8 @@ private struct TxHistoryRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("TrezorTxHistoryRow") + .accessibilityValue(tx.txid) } private var directionIcon: String { diff --git a/BitkitUITests/TrezorBridgeDashboardUITests.swift b/BitkitUITests/TrezorBridgeDashboardUITests.swift new file mode 100644 index 000000000..bd339ef8a --- /dev/null +++ b/BitkitUITests/TrezorBridgeDashboardUITests.swift @@ -0,0 +1,438 @@ +import Foundation +import UIKit +import XCTest + +final class TrezorBridgeDashboardUITests: XCTestCase { + private var app: XCUIApplication! + private let userEnv = TrezorUserEnvController() + private let regtest = RegtestRpcClient() + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["TEST_TREZOR_EMU"] == "1", "AI-only Trezor emulator tests are disabled") + } + + override func tearDownWithError() throws { + app?.terminate() + app = nil + try super.tearDownWithError() + } + + func testBridgeConnectLifecycleAndPromptContracts() { + launch() + connectBridgeDevice() + + XCTAssertTrue(app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 20)) + XCTAssertTrue(app.otherElements["TrezorDeviceInfoCard"].exists) + + app.buttons["TrezorSection-DeviceInfo"].tap() + scrollTo(app.buttons["TrezorClearCredentials"]) + app.buttons["TrezorClearCredentials"].tap() + + app.buttons["TrezorTestHook-Pin"].tap() + XCTAssertTrue(app.otherElements["TrezorPinSheet"].waitForExistence(timeout: 5)) + app.buttons["TrezorPinCancel"].tap() + + app.buttons["TrezorTestHook-Passphrase"].tap() + XCTAssertTrue(app.otherElements["TrezorPassphraseSheet"].waitForExistence(timeout: 5)) + app.buttons["TrezorPassphraseCancel"].tap() + + app.buttons["TrezorTestHook-Pairing"].tap() + XCTAssertTrue(app.otherElements["TrezorPairingSheet"].waitForExistence(timeout: 5)) + app.buttons["TrezorPairingCancel"].tap() + + app.buttons["TrezorTestHook-Confirm"].tap() + XCTAssertTrue(app.otherElements["TrezorConfirmOnDeviceOverlay"].waitForExistence(timeout: 5)) + app.buttons["TrezorConfirmOnDeviceCancel"].tap() + + app.buttons["TrezorDebugLogToggle"].tap() + XCTAssertTrue(app.buttons["TrezorDebugLogClear"].waitForExistence(timeout: 5)) + app.buttons["TrezorDebugLogClear"].tap() + + scrollTo(app.buttons["TrezorDisconnectButton"]) + app.buttons["TrezorDisconnectButton"].tap() + XCTAssertTrue(app.otherElements["TrezorDeviceList"].waitForExistence(timeout: 10)) + + connectBridgeDevice() + XCTAssertTrue(app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 20)) + } + + func testAddressPublicKeyMessageBalanceHistoryDetailAndSendFlow() throws { + launch() + connectBridgeDevice() + app.buttons["TrezorNetwork-Regtest"].tap() + + let generatedAddresses = try generateAllAddressTypes() + let fundingAddress = generatedAddresses["Native SegWit (P2WPKH)"] ?? generatedAddresses.values.first ?? "" + XCTAssertTrue(fundingAddress.hasPrefix("bcrt1"), "Expected a regtest SegWit address, got \(fundingAddress)") + + try regtest.fund(address: fundingAddress, bitcoin: 0.001) + try regtest.mineBlock() + + let xpub = try exportPublicKey() + try signAndVerifyMessage() + try lookupBalanceHistoryAndDetail(xpub: xpub, address: fundingAddress) + + let destinationAddress = try generateNextNativeSegwitAddress() + try sendSignAndBroadcast(xpub: xpub, destinationAddress: destinationAddress) + + app.buttons["TrezorDebugLogToggle"].tap() + let debugEntries = app.otherElements["TrezorDebugLogEntries"] + XCTAssertTrue(debugEntries.waitForExistence(timeout: 5)) + let debugText = debugEntries.label.lowercased() + XCTAssertFalse(debugText.contains("all all all all"), "Debug log must not expose the deterministic mnemonic") + XCTAssertFalse(debugText.contains("passphrase"), "Debug log must not expose passphrase payloads") + } + + func testBridgeUnavailableShowsRecoverableState() { + launch(bridgeUrl: "http://127.0.0.1:1") + + let scanButton = app.buttons["TrezorScanButton"] + if scanButton.waitForExistence(timeout: 10) { + scanButton.tap() + } + + let emptyState = app.otherElements["TrezorEmptyState"] + let errorBanner = app.otherElements["TrezorDeviceListError"] + XCTAssertTrue(emptyState.waitForExistence(timeout: 10) || errorBanner.waitForExistence(timeout: 10)) + } + + private func launch(bridgeUrl: String = "http://127.0.0.1:21325") { + app = XCUIApplication() + app.launchEnvironment = [ + "TEST_TREZOR_EMU": "1", + "TREZOR_BRIDGE": "true", + "TREZOR_BRIDGE_URL": bridgeUrl, + "TREZOR_ELECTRUM_URL": "tcp://127.0.0.1:60001", + "E2E": "true", + "E2E_BACKEND": "local", + "E2E_NETWORK": "regtest", + "GEO": "false", + ] + app.launch() + XCTAssertTrue(app.otherElements["TrezorRoot"].waitForExistence(timeout: 20)) + } + + private func connectBridgeDevice() { + if app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 2) { + return + } + + let scanButton = app.buttons["TrezorScanButton"] + if scanButton.waitForExistence(timeout: 10), scanButton.isHittable { + scanButton.tap() + } + + let bridgeDevice = app.buttons["TrezorDevice-bridge"] + XCTAssertTrue(bridgeDevice.waitForExistence(timeout: 20)) + + approveOnEmulator(for: 20) + bridgeDevice.tap() + XCTAssertTrue(app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 30)) + } + + private func generateAllAddressTypes() throws -> [String: String] { + app.buttons["TrezorSection-Address"].tap() + + var addresses: [String: String] = [:] + for addressType in ["Legacy (P2PKH)", "Nested SegWit (P2SH-P2WPKH)", "Native SegWit (P2WPKH)", "Taproot (P2TR)"] { + selectAddressType(addressType) + approveOnEmulator(for: 10) + app.buttons["TrezorGenerateAddress"].tap() + let address = readStaticText("TrezorGeneratedAddress", timeout: 20) + XCTAssertFalse(address.isEmpty) + addresses[addressType] = address + } + + XCTAssertTrue(addresses["Legacy (P2PKH)"]?.hasPrefix("m") == true || addresses["Legacy (P2PKH)"]?.hasPrefix("n") == true) + XCTAssertTrue(addresses["Nested SegWit (P2SH-P2WPKH)"]?.hasPrefix("2") == true) + XCTAssertTrue(addresses["Native SegWit (P2WPKH)"]?.hasPrefix("bcrt1q") == true) + XCTAssertTrue(addresses["Taproot (P2TR)"]?.hasPrefix("bcrt1p") == true) + XCTAssertTrue(app.images["TrezorGeneratedAddressQr"].exists || app.otherElements["TrezorGeneratedAddressQr"].exists) + + return addresses + } + + private func generateNextNativeSegwitAddress() throws -> String { + if !app.buttons["TrezorGenerateAddress"].exists { + app.buttons["TrezorSection-Address"].tap() + } + selectAddressType("Native SegWit (P2WPKH)") + app.buttons["TrezorAddressIndexIncrement"].tap() + approveOnEmulator(for: 10) + app.buttons["TrezorGenerateAddress"].tap() + let address = readStaticText("TrezorGeneratedAddress", timeout: 20) + XCTAssertTrue(address.hasPrefix("bcrt1q")) + return address + } + + private func selectAddressType(_ addressType: String) { + let picker = app.buttons["TrezorAddressType"].exists ? app.buttons["TrezorAddressType"] : app.otherElements["TrezorAddressType"] + if picker.exists { + picker.tap() + let option = app.buttons[addressType].exists ? app.buttons[addressType] : app.staticTexts[addressType] + XCTAssertTrue(option.waitForExistence(timeout: 5)) + option.tap() + } + } + + private func exportPublicKey() throws -> String { + app.buttons["TrezorSection-PublicKey"].tap() + approveOnEmulator(for: 10) + app.buttons["TrezorPublicKeyGet"].tap() + let xpub = readStaticText("TrezorXpub", timeout: 20) + XCTAssertTrue(xpub.hasPrefix("tpub") || xpub.hasPrefix("vpub") || xpub.hasPrefix("xpub") || xpub.hasPrefix("zpub")) + XCTAssertFalse(readStaticText("TrezorPublicKeyHex", timeout: 5).isEmpty) + return xpub + } + + private func signAndVerifyMessage() throws { + app.buttons["TrezorSection-SignMessage"].tap() + clearAndType(app.textFields["TrezorMessageToSign"], text: "Bitkit Trezor emulator test") + approveOnEmulator(for: 15) + app.buttons["TrezorSignMessageButton"].tap() + + let signature = readStaticText("TrezorSignature", timeout: 20) + let address = readStaticText("TrezorSignedMessageAddress", timeout: 5) + XCTAssertFalse(signature.isEmpty) + XCTAssertFalse(address.isEmpty) + + app.segmentedControls["TrezorSignMessageMode"].buttons["Verify"].tap() + clearAndType(app.textFields["TrezorVerifyAddress"], text: address) + clearAndType(app.textFields["TrezorVerifySignature"], text: signature) + clearAndType(app.textFields["TrezorVerifyMessage"], text: "Bitkit Trezor emulator test") + app.buttons["TrezorVerifySignatureButton"].tap() + XCTAssertTrue(app.otherElements["TrezorSignatureValid"].waitForExistence(timeout: 10)) + + clearAndType(app.textFields["TrezorVerifyMessage"], text: "Tampered Bitkit Trezor emulator test") + app.buttons["TrezorVerifySignatureButton"].tap() + XCTAssertTrue(app.otherElements["TrezorSignatureInvalid"].waitForExistence(timeout: 10)) + } + + private func lookupBalanceHistoryAndDetail(xpub: String, address: String) throws { + app.buttons["TrezorSection-BalanceLookup"].tap() + clearAndType(app.textFields["TrezorLookupInput"], text: address) + app.buttons["TrezorLookupButton"].tap() + XCTAssertTrue(app.otherElements["TrezorAddressLookupResult"].waitForExistence(timeout: 30)) + + clearAndType(app.textFields["TrezorLookupInput"], text: xpub) + app.buttons["TrezorLookupButton"].tap() + XCTAssertTrue(app.otherElements["TrezorAccountResult"].waitForExistence(timeout: 30)) + XCTAssertTrue(app.otherElements["TrezorUtxoList"].waitForExistence(timeout: 30)) + + app.buttons["TrezorSection-TxHistory"].tap() + clearAndType(app.textFields["TrezorTxHistoryInput"], text: xpub) + app.buttons["TrezorTxHistoryButton"].tap() + let txRow = app.otherElements["TrezorTxHistoryRow"] + XCTAssertTrue(txRow.waitForExistence(timeout: 30)) + let txid = txRow.value as? String ?? txRow.label + XCTAssertFalse(txid.isEmpty) + + app.buttons["TrezorSection-TxDetail"].tap() + clearAndType(app.textFields["TrezorTxDetailXpub"], text: xpub) + clearAndType(app.textFields["TrezorTxDetailTxid"], text: txid) + app.buttons["TrezorTxDetailButton"].tap() + XCTAssertTrue(app.otherElements["TrezorTxDetailOverview"].waitForExistence(timeout: 30)) + XCTAssertFalse(readStaticText("TrezorTxDetailResultTxid", timeout: 5).isEmpty) + } + + private func sendSignAndBroadcast(xpub: String, destinationAddress: String) throws { + if !app.textFields["TrezorLookupInput"].exists { + app.buttons["TrezorSection-BalanceLookup"].tap() + } + clearAndType(app.textFields["TrezorLookupInput"], text: xpub) + app.buttons["TrezorLookupButton"].tap() + XCTAssertTrue(app.otherElements["TrezorSendSection"].waitForExistence(timeout: 30)) + + scrollTo(app.textFields["TrezorSendAddress"]) + clearAndType(app.textFields["TrezorSendAddress"], text: destinationAddress) + clearAndType(app.textFields["TrezorSendAmount"], text: "1000") + clearAndType(app.textFields["TrezorSendFeeRate"], text: "2") + app.buttons["TrezorComposeButton"].tap() + XCTAssertTrue(app.otherElements["TrezorComposeReview"].waitForExistence(timeout: 30)) + + approveOnEmulator(for: 30) + app.buttons["TrezorSignTxButton"].tap() + XCTAssertTrue(app.otherElements["TrezorSignedTxResult"].waitForExistence(timeout: 40)) + + app.buttons["TrezorBroadcastButton"].tap() + XCTAssertTrue(app.otherElements["TrezorBroadcastResult"].waitForExistence(timeout: 30)) + XCTAssertFalse(readStaticText("TrezorBroadcastTxid", timeout: 5).isEmpty) + } + + private func approveOnEmulator(for seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + DispatchQueue.global(qos: .userInitiated).async { [userEnv] in + while Date() < deadline { + try? userEnv.send(type: "emulator-press-yes") + Thread.sleep(forTimeInterval: 0.4) + } + } + } + + private func readStaticText(_ identifier: String, timeout: TimeInterval) -> String { + let element = app.staticTexts[identifier] + XCTAssertTrue(element.waitForExistence(timeout: timeout), "Missing static text \(identifier)") + return element.label + } + + private func clearAndType(_ element: XCUIElement, text: String) { + XCTAssertTrue(element.waitForExistence(timeout: 10), "Missing text input \(element)") + scrollTo(element) + element.tap() + if let currentValue = element.value as? String, !currentValue.isEmpty { + element.press(forDuration: 1.0) + app.menuItems["Select All"].tapIfExists() + element.typeText(XCUIKeyboardKey.delete.rawValue) + } + element.typeText(text) + } + + private func scrollTo(_ element: XCUIElement, maxSwipes: Int = 8) { + guard !element.isHittable else { return } + for _ in 0 ..< maxSwipes where !element.isHittable { + app.swipeUp() + } + } +} + +private final class TrezorUserEnvController { + private let url = URL(string: "ws://127.0.0.1:9001")! + private var nextId = 0 + + func send(type: String, extra: [String: Any] = [:]) throws { + nextId += 1 + var payload = extra + payload["type"] = type + payload["id"] = nextId + + let jsonData = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: jsonData, encoding: .utf8) ?? "{}" + let session = URLSession(configuration: .ephemeral) + let task = session.webSocketTask(with: url) + task.resume() + defer { + task.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + } + + _ = try receiveString(from: task) + try sendString(json, to: task) + let response = try receiveString(from: task) + let responseData = Data(response.utf8) + guard let parsed = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + parsed["success"] as? Bool == true + else { + throw ControllerError.unsuccessful(response) + } + } + + private func sendString(_ value: String, to task: URLSessionWebSocketTask) throws { + let semaphore = DispatchSemaphore(value: 0) + var thrown: Error? + task.send(.string(value)) { error in + thrown = error + semaphore.signal() + } + semaphore.wait() + if let thrown { throw thrown } + } + + private func receiveString(from task: URLSessionWebSocketTask) throws -> String { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + task.receive { receiveResult in + switch receiveResult { + case let .success(.string(value)): + result = .success(value) + case let .success(.data(data)): + result = .success(String(data: data, encoding: .utf8) ?? "") + case let .failure(error): + result = .failure(error) + @unknown default: + result = .failure(ControllerError.unsupportedMessage) + } + semaphore.signal() + } + semaphore.wait() + return try result?.get() ?? "" + } + + private enum ControllerError: Error { + case unsuccessful(String) + case unsupportedMessage + } +} + +private final class RegtestRpcClient { + private let url = URL(string: "http://127.0.0.1:43782")! + private let authHeader = "Basic \(Data("polaruser:polarpass".utf8).base64EncodedString())" + private var requestId = 0 + + func fund(address: String, bitcoin: Decimal) throws { + _ = try call(method: "sendtoaddress", params: [address, NSDecimalNumber(decimal: bitcoin).doubleValue]) + } + + func mineBlock() throws { + let miningAddress = try call(method: "getnewaddress", params: []) as? String + _ = try call(method: "generatetoaddress", params: [1, miningAddress ?? "bcrt1qdt5h3vzjqxhjnm3cmg8k5eqwxjc9k0cgg3vj3z"]) + } + + @discardableResult + private func call(method: String, params: [Any]) throws -> Any? { + requestId += 1 + let payload: [String: Any] = [ + "jsonrpc": "1.0", + "id": requestId, + "method": method, + "params": params, + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { + result = .failure(error) + return + } + guard let response = response as? HTTPURLResponse, 200 ..< 300 ~= response.statusCode else { + result = .failure(RpcError.http) + return + } + result = .success(data ?? Data()) + }.resume() + semaphore.wait() + + let data = try result?.get() ?? Data() + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw RpcError.invalidResponse + } + if let error = json["error"] as? [String: Any], !error.isEmpty { + throw RpcError.rpc(error) + } + return json["result"] + } + + private enum RpcError: Error { + case http + case invalidResponse + case rpc([String: Any]) + } +} + +private extension XCUIElement { + func tapIfExists() { + if waitForExistence(timeout: 2), isHittable { + tap() + } + } +} diff --git a/Docs/AI_DEVICE_TESTS.md b/Docs/AI_DEVICE_TESTS.md new file mode 100644 index 000000000..9a554571e --- /dev/null +++ b/Docs/AI_DEVICE_TESTS.md @@ -0,0 +1,34 @@ +# AI Device Tests + +These tests are for developer-triggered AI device validation only. They are not part of default CI. + +## Trezor Emulator + +Start the emulator stack from a sibling `bitkit-docker` checkout on `main`: + +```bash +cd /path/to/bitkit-docker +docker compose up -d +./scripts/trezor-emulator start +``` + +Run the iOS suite from this repository: + +```bash +TEST_TREZOR_EMU=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 \ +xcodebuild test \ + -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme BitkitAITests \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -derivedDataPath DerivedData \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ + -parallel-testing-enabled NO +``` + +The equivalent GitHub Actions entry point is the manual `ai-device-tests` workflow with suite `trezor-emu`. From 7aebd35742452946ad4f9536e8154167b589a0bd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 21 May 2026 21:39:02 +0200 Subject: [PATCH 2/4] docs: clarify trezor emulator transport --- Docs/AI_DEVICE_TESTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Docs/AI_DEVICE_TESTS.md b/Docs/AI_DEVICE_TESTS.md index 9a554571e..fd46fa5b5 100644 --- a/Docs/AI_DEVICE_TESTS.md +++ b/Docs/AI_DEVICE_TESTS.md @@ -32,3 +32,9 @@ xcodebuild test \ ``` The equivalent GitHub Actions entry point is the manual `ai-device-tests` workflow with suite `trezor-emu`. + +## Why Bridge + +Current iOS releases do not expose WebUSB-style access for devices like Trezor in Safari/WebKit. The connected-device path on iOS is Bluetooth for Trezor models that support it. + +The `bitkit-docker` emulator tooling still stays useful for iOS automation because Trezor User Env exposes the emulator through Trezor Bridge on the Mac. The simulator can use that localhost Bridge endpoint while exercising the same dashboard behavior. If Trezor User Env later exposes a BLE peripheral that CoreBluetooth can scan, this suite should move to that transport without changing the dashboard coverage. From 2e3c9a13cfba885b3d9201c87872460cd83d8b46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 21 May 2026 23:36:38 +0200 Subject: [PATCH 3/4] test: stabilize Trezor emulator UI suite --- .github/workflows/ai-device-tests.yml | 3 +- Bitkit.xcodeproj/project.pbxproj | 9 +- Bitkit/BitkitApp.swift | 6 +- .../Components/Trezor/TrezorDeviceRow.swift | 8 +- .../Components/Trezor/TrezorStatusBadge.swift | 4 +- Bitkit/Constants/Env.swift | 34 ++- .../Trezor/TrezorCredentialStorage.swift | 13 ++ .../Trezor/TrezorKnownDeviceStorage.swift | 5 + .../ViewModels/Trezor/TrezorViewModel.swift | 11 + Bitkit/Views/Trezor/TrezorAddressView.swift | 4 +- .../Trezor/TrezorBalanceLookupView.swift | 8 +- Bitkit/Views/Trezor/TrezorConnectedView.swift | 4 +- .../Trezor/TrezorDeviceFeaturesView.swift | 6 +- .../Views/Trezor/TrezorDeviceListView.swift | 12 +- Bitkit/Views/Trezor/TrezorPublicKeyView.swift | 2 +- Bitkit/Views/Trezor/TrezorRootView.swift | 54 +++-- .../Trezor/TrezorSendTransactionView.swift | 10 +- .../Views/Trezor/TrezorSignMessageView.swift | 12 +- .../Trezor/TrezorTransactionDetailView.swift | 10 +- .../Trezor/TrezorTransactionHistoryView.swift | 6 +- .../TrezorBridgeDashboardUITests.swift | 220 +++++++++++++++--- Docs/AI_DEVICE_TESTS.md | 3 +- 22 files changed, 352 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index 5d6eaafe7..bc9a4a027 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -78,6 +78,7 @@ jobs: - 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" @@ -97,7 +98,7 @@ jobs: -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$SIMULATOR_OS" \ -derivedDataPath DerivedData \ -resultBundlePath TestResults/TrezorBridgeDashboardUITests.xcresult \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ -parallel-testing-enabled NO \ | xcbeautify diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index d82d212ba..4a1ba5ebb 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -434,23 +434,24 @@ /* Begin PBXShellScriptBuildPhase section */ 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( - "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework/_CodeSignature/CodeResources", ); name = "Remove Static Framework Stubs"; outputFileListPaths = ( ); outputPaths = ( - "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/.ldk-stubs-removed", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/.ldk-stubs-removed", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Remove static framework stubs from app bundle\\n# LDKNodeFFI is a static library - its code is linked into the main executable.\\n# The empty framework structure causes iOS install errors.\\nFRAMEWORK_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework\"\\nOUTPUT_SENTINEL=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/.ldk-stubs-removed\"\\n\\nif [ -d \"$FRAMEWORK_PATH\" ]; then\\n echo \"Removing LDKNodeFFI static framework stub...\"\\n rm -rf \"$FRAMEWORK_PATH\"\\n echo \"Done.\"\\nfi\\ntouch \"$OUTPUT_SENTINEL\"\\n"; + shellScript = "# Remove static framework stubs from app bundle\n# LDKNodeFFI is a static library - its code is linked into the main executable.\n# The empty framework structure causes iOS install errors.\nFRAMEWORK_PATH=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework\"\nOUTPUT_SENTINEL=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}/.ldk-stubs-removed\"\n\nif [ -d \"$FRAMEWORK_PATH\" ]; then\n echo \"Removing LDKNodeFFI static framework stub...\"\n rm -rf \"$FRAMEWORK_PATH\"\n if [ -d \"$FRAMEWORK_PATH\" ]; then\n echo \"error: Failed to remove LDKNodeFFI static framework stub\" >&2\n exit 1\n fi\n echo \"Done.\"\nfi\ntouch \"$OUTPUT_SENTINEL\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -696,6 +697,7 @@ DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; @@ -744,6 +746,7 @@ DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index a9e439c18..be9e09e1a 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -96,12 +96,16 @@ struct BitkitApp: App { init() { UIWindow.appearance().overrideUserInterfaceStyle = .dark + if Env.shouldResetTrezorEmulatorState { + TrezorKnownDeviceStorage.removeAll() + TrezorCredentialStorage.deleteAll() + } _ = ToastWindowManager.shared } var body: some Scene { WindowGroup { - if Env.isUnitTest { + if Env.isUnitTest, !Env.isTrezorEmulatorTesting { Text("Running tests...") } else { ContentView() diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift index c1e81c684..7c8feacfc 100644 --- a/Bitkit/Components/Trezor/TrezorDeviceRow.swift +++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift @@ -138,6 +138,7 @@ struct KnownDeviceRow: View { } .buttonStyle(.plain) .disabled(isConnecting) + .accessibilityIdentifier("TrezorKnownDeviceConnect-\(accessibilitySuffix)") // Forget button Button(action: onForget) { @@ -147,11 +148,16 @@ struct KnownDeviceRow: View { .padding(10) } .buttonStyle(.plain) + .accessibilityIdentifier("TrezorForgetDevice-\(accessibilitySuffix)") } .padding(16) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) - .accessibilityIdentifier("TrezorKnownDevice-\(device.id)") + .trezorAccessibilityAnchor("TrezorKnownDevice-\(accessibilitySuffix)") + } + + private var accessibilitySuffix: String { + device.path.hasPrefix("bridge:") ? "bridge" : device.id } } diff --git a/Bitkit/Components/Trezor/TrezorStatusBadge.swift b/Bitkit/Components/Trezor/TrezorStatusBadge.swift index fa96dcd90..ff3ebeb6c 100644 --- a/Bitkit/Components/Trezor/TrezorStatusBadge.swift +++ b/Bitkit/Components/Trezor/TrezorStatusBadge.swift @@ -69,7 +69,7 @@ struct TrezorConfirmOnDeviceOverlay: View { .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.9)) - .accessibilityIdentifier("TrezorConfirmOnDeviceOverlay") + .trezorAccessibilityAnchor("TrezorConfirmOnDeviceOverlay") } } @@ -120,7 +120,7 @@ struct TrezorErrorBanner: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("TrezorErrorBanner") + .trezorAccessibilityAnchor("TrezorErrorBanner") } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index e07e30191..ce029cfd6 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -35,9 +35,33 @@ enum Env { if envValue?.isEmpty == false { return envValue } + if let argumentValue = launchArgumentValue(key) { + return argumentValue + } return infoPlistValue(key) } + private static func launchArgumentValue(_ key: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + for argument in arguments { + for prefix in ["-\(key)=", "--\(key)=", "\(key)="] where argument.hasPrefix(prefix) { + let value = String(argument.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + } + + for (index, argument) in arguments.enumerated() where argument == "-\(key)" || argument == "--\(key)" || argument == key { + let nextIndex = index + 1 + guard nextIndex < arguments.count else { return "true" } + let value = arguments[nextIndex] + guard !value.hasPrefix("-") else { return "true" } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + return nil + } + private static func boolConfigValue(_ key: String) -> Bool { guard let value = configValue(key)?.lowercased() else { return false } return ["1", "true", "yes", "y"].contains(value) @@ -167,7 +191,15 @@ enum Env { } static var isTrezorEmulatorTesting: Bool { - (isDebug || isE2E) && boolConfigValue("TEST_TREZOR_EMU") + #if TEST_TREZOR_EMU + return isDebug || isE2E + #else + return (isDebug || isE2E) && boolConfigValue("TEST_TREZOR_EMU") + #endif + } + + static var shouldResetTrezorEmulatorState: Bool { + isTrezorEmulatorTesting && boolConfigValue("TEST_TREZOR_RESET_STATE") } static var appStorageUrl: URL { diff --git a/Bitkit/Services/Trezor/TrezorCredentialStorage.swift b/Bitkit/Services/Trezor/TrezorCredentialStorage.swift index 88993193f..e7596ebeb 100644 --- a/Bitkit/Services/Trezor/TrezorCredentialStorage.swift +++ b/Bitkit/Services/Trezor/TrezorCredentialStorage.swift @@ -98,6 +98,19 @@ enum TrezorCredentialStorage { } } + /// Delete all stored THP credentials. + static func deleteAll() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + ] + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess, status != errSecItemNotFound { + Logger.warn("Failed to delete all THP credentials: \(status)", context: "TrezorCredentialStorage") + } + } + /// List all device IDs with stored credentials /// - Returns: Array of device IDs (sanitized form) static func listAllDeviceIds() -> [String] { diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift index 9b747b621..76c7894c1 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift @@ -42,6 +42,11 @@ enum TrezorKnownDeviceStorage { } } + /// Remove all remembered Trezor devices. + static func removeAll() { + UserDefaults.standard.removeObject(forKey: key) + } + /// Check if a device is known static func isKnown(id: String) -> Bool { loadAll().contains { $0.id == id } diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index c231f53d9..3f47ea5bb 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -118,6 +118,10 @@ class TrezorViewModel { /// Status text during auto-reconnect var autoReconnectStatus: String? + /// Prevents a user-initiated disconnect from immediately reconnecting + /// when the disconnected device list appears. + private var suppressNextAutoReconnect = false + // MARK: - Address Index /// Current address index (last path component) @@ -386,6 +390,7 @@ class TrezorViewModel { /// Connect to a device func connect(device: TrezorDeviceInfo) async { error = nil + suppressNextAutoReconnect = false trezorLog("=== Connecting to device: \(device.path) ===") @@ -408,6 +413,7 @@ class TrezorViewModel { /// Disconnect from current device func disconnect() async { guard connectedDevice != nil else { return } + suppressNextAutoReconnect = true do { try await trezorService.disconnect() @@ -640,6 +646,11 @@ class TrezorViewModel { func autoReconnect() async { guard !knownDevices.isEmpty else { return } guard !isAutoReconnecting else { return } + if suppressNextAutoReconnect { + suppressNextAutoReconnect = false + trezorLog("Auto-reconnect: skipped after manual disconnect") + return + } isAutoReconnecting = true autoReconnectStatus = "Scanning for known devices..." diff --git a/Bitkit/Views/Trezor/TrezorAddressView.swift b/Bitkit/Views/Trezor/TrezorAddressView.swift index cc1322184..95537e0e7 100644 --- a/Bitkit/Views/Trezor/TrezorAddressView.swift +++ b/Bitkit/Views/Trezor/TrezorAddressView.swift @@ -154,7 +154,7 @@ private struct DerivationPathSection: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 8)) - .accessibilityIdentifier("TrezorAddressIndex") + .trezorAccessibilityAnchor("TrezorAddressIndex") Button(action: { trezor.derivationPath = selectedScriptType.defaultPath(coinType: trezor.coinTypeComponent) @@ -291,7 +291,7 @@ private struct GeneratedAddressCard: View { .frame(maxWidth: .infinity) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) - .accessibilityIdentifier("TrezorGeneratedAddressCard") + .trezorAccessibilityAnchor("TrezorGeneratedAddressCard") } } diff --git a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift index 5e2860a8a..2e551f544 100644 --- a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift +++ b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift @@ -274,7 +274,7 @@ private struct AccountResultSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .accessibilityIdentifier("TrezorAccountResult") + .trezorAccessibilityAnchor("TrezorAccountResult") } private func accountTypeLabel(_ type: AccountType) -> String { @@ -313,7 +313,7 @@ private struct AddressResultSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .accessibilityIdentifier("TrezorAddressLookupResult") + .trezorAccessibilityAnchor("TrezorAddressLookupResult") } } @@ -359,7 +359,7 @@ private struct UTXOListSection: View { } } } - .accessibilityIdentifier("TrezorUtxoList") + .trezorAccessibilityAnchor("TrezorUtxoList") } } } @@ -444,7 +444,7 @@ private struct UTXORow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorUtxoRow") + .trezorAccessibilityAnchor("TrezorUtxoRow") } private var truncatedTxid: String { diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index 672aa621a..94c805698 100644 --- a/Bitkit/Views/Trezor/TrezorConnectedView.swift +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -124,7 +124,7 @@ struct TrezorConnectedView: View { .background(Color.black) .navigationTitle("Trezor") .navigationBarTitleDisplayMode(.inline) - .accessibilityIdentifier("TrezorConnectedView") + .trezorAccessibilityAnchor("TrezorConnectedView") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { TrezorStatusBadge( @@ -180,7 +180,7 @@ private struct DeviceInfoCard: View { .frame(maxWidth: .infinity) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) - .accessibilityIdentifier("TrezorDeviceInfoCard") + .trezorAccessibilityAnchor("TrezorDeviceInfoCard") } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift index 346c1a442..322f4ef93 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift @@ -16,7 +16,7 @@ struct TrezorDeviceFeaturesContent: View { NoFeaturesView() } } - .accessibilityIdentifier("TrezorDeviceFeatures") + .trezorAccessibilityAnchor("TrezorDeviceFeatures") } } @@ -184,7 +184,7 @@ private struct NoFeaturesView: View { .foregroundColor(.white.opacity(0.6)) } .padding(48) - .accessibilityIdentifier("TrezorNoFeatures") + .trezorAccessibilityAnchor("TrezorNoFeatures") } } @@ -208,7 +208,7 @@ private struct InfoSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .accessibilityIdentifier("TrezorDeviceInfo-\(title.replacingOccurrences(of: " ", with: ""))") + .trezorAccessibilityAnchor("TrezorDeviceInfo-\(title.replacingOccurrences(of: " ", with: ""))") } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 495c0b711..7eeafad35 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -115,7 +115,7 @@ struct TrezorDeviceListView: View { .background(Color.black) .navigationTitle("Connect Trezor") .navigationBarTitleDisplayMode(.inline) - .accessibilityIdentifier("TrezorDeviceList") + .trezorAccessibilityAnchor("TrezorDeviceList") .task { trezor.loadKnownDevices() @@ -186,7 +186,7 @@ private struct AutoReconnectIndicator: View { .foregroundColor(.white.opacity(0.6)) } .padding(16) - .accessibilityIdentifier("TrezorAutoReconnectIndicator") + .trezorAccessibilityAnchor("TrezorAutoReconnectIndicator") } } @@ -212,7 +212,7 @@ private struct BluetoothStatusCard: View { .frame(maxWidth: .infinity) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 16)) - .accessibilityIdentifier("TrezorBluetoothStatus") + .trezorAccessibilityAnchor("TrezorBluetoothStatus") } private var statusTitle: String { @@ -259,7 +259,7 @@ private struct ScanningIndicator: View { .multilineTextAlignment(.center) } .padding(32) - .accessibilityIdentifier("TrezorScanningIndicator") + .trezorAccessibilityAnchor("TrezorScanningIndicator") } } @@ -282,7 +282,7 @@ private struct TrezorEmptyStateView: View { } } .padding(32) - .accessibilityIdentifier("TrezorEmptyState") + .trezorAccessibilityAnchor("TrezorEmptyState") } } @@ -303,7 +303,7 @@ private struct ErrorCard: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("TrezorDeviceListError") + .trezorAccessibilityAnchor("TrezorDeviceListError") } } diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift index cc6e7b34b..cb51ae12e 100644 --- a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift +++ b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift @@ -143,7 +143,7 @@ private struct CopyableField: View { .padding(16) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("\(accessibilityIdentifier)Card") + .trezorAccessibilityAnchor("\(accessibilityIdentifier)Card") } } diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index b93e5f439..0c228c3c0 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -7,26 +7,50 @@ import SwiftUI /// are isolated in child views so this view doesn't re-render on every property change. struct TrezorRootView: View { var body: some View { - VStack(spacing: 0) { - NetworkSelectorRow() - + ZStack(alignment: .topLeading) { if Env.isTrezorEmulatorTesting { - TrezorEmulatorTestHooksView() + TrezorAccessibilityAnchor(id: "TrezorRoot") } - ZStack(alignment: .bottom) { - TrezorContentSwitcher() - .frame(maxHeight: .infinity) - .padding(.bottom, 40) + VStack(spacing: 0) { + NetworkSelectorRow() + + if Env.isTrezorEmulatorTesting { + TrezorEmulatorTestHooksView() + } + + ZStack(alignment: .bottom) { + TrezorContentSwitcher() + .frame(maxHeight: .infinity) + .padding(.bottom, 40) - TrezorDebugLogWrapper() + TrezorDebugLogWrapper() + } } } - .accessibilityIdentifier("TrezorRoot") .modifier(TrezorDialogsModifier()) } } +struct TrezorAccessibilityAnchor: View { + let id: String + + var body: some View { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier(id) + } +} + +extension View { + func trezorAccessibilityAnchor(_ id: String) -> some View { + overlay(alignment: .topLeading) { + TrezorAccessibilityAnchor(id: id) + } + } +} + // MARK: - Content Switcher /// Isolates the connected/disconnected toggle so only this view @@ -201,7 +225,7 @@ struct TrezorPinEntrySheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() - .accessibilityIdentifier("TrezorPinSheet") + .trezorAccessibilityAnchor("TrezorPinSheet") } } @@ -277,7 +301,7 @@ struct TrezorPairingCodeSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() - .accessibilityIdentifier("TrezorPairingSheet") + .trezorAccessibilityAnchor("TrezorPairingSheet") .onChange(of: code) { newValue in if newValue.count == digitCount { guard !hasSubmitted else { return } @@ -400,7 +424,7 @@ struct TrezorPassphraseSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() - .accessibilityIdentifier("TrezorPassphraseSheet") + .trezorAccessibilityAnchor("TrezorPassphraseSheet") .task { focusedField = .passphrase } @@ -462,7 +486,7 @@ private struct TrezorEmulatorTestHooksView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.white.opacity(0.04)) - .accessibilityIdentifier("TrezorTestHooks") + .trezorAccessibilityAnchor("TrezorTestHooks") } } @@ -550,7 +574,7 @@ struct TrezorDebugLogPanel: View { .padding(.horizontal, 16) } .frame(maxHeight: 300) - .accessibilityIdentifier("TrezorDebugLogEntries") + .trezorAccessibilityAnchor("TrezorDebugLogEntries") .onChange(of: debugLog.entries.count) { _ in if let lastIndex = debugLog.entries.indices.last { proxy.scrollTo(lastIndex, anchor: .bottom) diff --git a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift index e8727f819..3a4ad23b2 100644 --- a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift +++ b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift @@ -74,7 +74,7 @@ struct SendTransactionSection: View { TrezorErrorBanner(message: sendError) } } - .accessibilityIdentifier("TrezorSendSection") + .trezorAccessibilityAnchor("TrezorSendSection") } } @@ -312,7 +312,7 @@ private struct ReviewSectionView: View { .foregroundColor(.white.opacity(0.4)) } } - .accessibilityIdentifier("TrezorComposeReview") + .trezorAccessibilityAnchor("TrezorComposeReview") } } } @@ -355,7 +355,7 @@ private struct PSBTPreview: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorPsbtPreview") + .trezorAccessibilityAnchor("TrezorPsbtPreview") } } @@ -453,7 +453,7 @@ private struct SignedResultSectionView: View { } .accessibilityIdentifier("TrezorNewTransaction") } - .accessibilityIdentifier("TrezorSignedTxResult") + .trezorAccessibilityAnchor("TrezorSignedTxResult") } } @@ -494,7 +494,7 @@ private struct BroadcastResultCard: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorBroadcastResult") + .trezorAccessibilityAnchor("TrezorBroadcastResult") } } diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift index cea048cff..01b600383 100644 --- a/Bitkit/Views/Trezor/TrezorSignMessageView.swift +++ b/Bitkit/Views/Trezor/TrezorSignMessageView.swift @@ -10,6 +10,7 @@ enum TrezorSignMessageTab: String, CaseIterable { /// Inline content for message signing, used by expandable section. struct TrezorSignMessageContent: View { + @Environment(TrezorViewModel.self) private var trezor @State private var verifyAddress: String = "" @State private var verifySignature: String = "" @State private var verifyMessage: String = "" @@ -38,6 +39,13 @@ struct TrezorSignMessageContent: View { ) } } + .onChange(of: selectedTab) { _, tab in + guard tab == .verify, let signedMessage = trezor.signedMessage else { return } + verifyAddress = signedMessage.address + verifySignature = signedMessage.signature + verifyMessage = trezor.messageToSign + verificationResult = nil + } } } @@ -326,7 +334,7 @@ private struct SignedMessageResult: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.green.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("TrezorSignedMessageResult") + .trezorAccessibilityAnchor("TrezorSignedMessageResult") } } @@ -349,7 +357,7 @@ private struct VerificationResultBanner: View { .padding(16) .background((isValid ? Color.green : Color.red).opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier(isValid ? "TrezorSignatureValid" : "TrezorSignatureInvalid") + .trezorAccessibilityAnchor(isValid ? "TrezorSignatureValid" : "TrezorSignatureInvalid") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift index 5c8dbfefc..7e89419fb 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift @@ -289,7 +289,7 @@ private struct TxDetailOverviewSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .accessibilityIdentifier("TrezorTxDetailOverview") + .trezorAccessibilityAnchor("TrezorTxDetailOverview") } private var directionLabel: String { @@ -330,7 +330,7 @@ private struct TxDetailInputsSection: View { } } } - .accessibilityIdentifier("TrezorTxDetailInputs") + .trezorAccessibilityAnchor("TrezorTxDetailInputs") } } } @@ -405,7 +405,7 @@ private struct TxInputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorTxDetailInputRow") + .trezorAccessibilityAnchor("TrezorTxDetailInputRow") } private var truncatedTxid: String { @@ -435,7 +435,7 @@ private struct TxDetailOutputsSection: View { } } } - .accessibilityIdentifier("TrezorTxDetailOutputs") + .trezorAccessibilityAnchor("TrezorTxDetailOutputs") } } } @@ -489,7 +489,7 @@ private struct TxOutputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorTxDetailOutputRow") + .trezorAccessibilityAnchor("TrezorTxDetailOutputRow") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift index c6b892741..a43067baf 100644 --- a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift +++ b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift @@ -189,7 +189,7 @@ private struct TxHistorySummarySection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .accessibilityIdentifier("TrezorTxHistorySummary") + .trezorAccessibilityAnchor("TrezorTxHistorySummary") } private func accountTypeLabel(_ type: AccountType) -> String { @@ -220,7 +220,7 @@ private struct TxHistoryListSection: View { } } } - .accessibilityIdentifier("TrezorTxHistoryList") + .trezorAccessibilityAnchor("TrezorTxHistoryList") } } } @@ -320,7 +320,7 @@ private struct TxHistoryRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) - .accessibilityIdentifier("TrezorTxHistoryRow") + .trezorAccessibilityAnchor("TrezorTxHistoryRow") .accessibilityValue(tx.txid) } diff --git a/BitkitUITests/TrezorBridgeDashboardUITests.swift b/BitkitUITests/TrezorBridgeDashboardUITests.swift index bd339ef8a..b11383a87 100644 --- a/BitkitUITests/TrezorBridgeDashboardUITests.swift +++ b/BitkitUITests/TrezorBridgeDashboardUITests.swift @@ -5,17 +5,27 @@ import XCTest final class TrezorBridgeDashboardUITests: XCTestCase { private var app: XCUIApplication! private let userEnv = TrezorUserEnvController() + private let approvalLock = NSLock() private let regtest = RegtestRpcClient() + private var approvalDeadline = Date.distantPast + private var isApprovalLoopRunning = false override func setUpWithError() throws { try super.setUpWithError() continueAfterFailure = false - try XCTSkipUnless(ProcessInfo.processInfo.environment["TEST_TREZOR_EMU"] == "1", "AI-only Trezor emulator tests are disabled") + #if TEST_TREZOR_EMU + let isEnabled = true + #else + let isEnabled = ProcessInfo.processInfo.environment["TEST_TREZOR_EMU"] == "1" + #endif + try XCTSkipUnless(isEnabled, "AI-only Trezor emulator tests are disabled") } override func tearDownWithError() throws { + stopApprovingOnEmulator() app?.terminate() app = nil + releaseBridgeSessionIfAny() try super.tearDownWithError() } @@ -49,10 +59,19 @@ final class TrezorBridgeDashboardUITests: XCTestCase { app.buttons["TrezorDebugLogToggle"].tap() XCTAssertTrue(app.buttons["TrezorDebugLogClear"].waitForExistence(timeout: 5)) app.buttons["TrezorDebugLogClear"].tap() + app.buttons["TrezorDebugLogToggle"].tap() scrollTo(app.buttons["TrezorDisconnectButton"]) app.buttons["TrezorDisconnectButton"].tap() - XCTAssertTrue(app.otherElements["TrezorDeviceList"].waitForExistence(timeout: 10)) + XCTAssertTrue( + app.otherElements["TrezorDeviceList"].waitForExistence(timeout: 10), + "Device list did not appear after disconnect: \(app.debugDescription)" + ) + + let forgetButton = app.buttons["TrezorForgetDevice-bridge"] + XCTAssertTrue(forgetButton.waitForExistence(timeout: 5), "Known bridge device was not available to forget: \(app.debugDescription)") + forgetButton.tap() + XCTAssertFalse(app.buttons["TrezorKnownDeviceConnect-bridge"].waitForExistence(timeout: 3)) connectBridgeDevice() XCTAssertTrue(app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 20)) @@ -102,6 +121,7 @@ final class TrezorBridgeDashboardUITests: XCTestCase { app = XCUIApplication() app.launchEnvironment = [ "TEST_TREZOR_EMU": "1", + "TEST_TREZOR_RESET_STATE": "1", "TREZOR_BRIDGE": "true", "TREZOR_BRIDGE_URL": bridgeUrl, "TREZOR_ELECTRUM_URL": "tcp://127.0.0.1:60001", @@ -110,8 +130,18 @@ final class TrezorBridgeDashboardUITests: XCTestCase { "E2E_NETWORK": "regtest", "GEO": "false", ] + app.launchArguments = [ + "-TEST_TREZOR_EMU", "1", + "-TEST_TREZOR_RESET_STATE", "1", + "-TREZOR_BRIDGE", "true", + "-TREZOR_BRIDGE_URL", bridgeUrl, + "-TREZOR_ELECTRUM_URL", "tcp://127.0.0.1:60001", + ] app.launch() - XCTAssertTrue(app.otherElements["TrezorRoot"].waitForExistence(timeout: 20)) + let root = app.otherElements["TrezorRoot"] + if !root.waitForExistence(timeout: 20) { + XCTFail("TrezorRoot did not appear. Accessibility tree: \(app.debugDescription)") + } } private func connectBridgeDevice() { @@ -127,28 +157,45 @@ final class TrezorBridgeDashboardUITests: XCTestCase { let bridgeDevice = app.buttons["TrezorDevice-bridge"] XCTAssertTrue(bridgeDevice.waitForExistence(timeout: 20)) - approveOnEmulator(for: 20) bridgeDevice.tap() - XCTAssertTrue(app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 30)) + XCTAssertTrue( + app.otherElements["TrezorConnectedView"].waitForExistence(timeout: 60), + "Bridge device was tapped but the dashboard never reached connected state: \(app.debugDescription)" + ) } private func generateAllAddressTypes() throws -> [String: String] { app.buttons["TrezorSection-Address"].tap() var addresses: [String: String] = [:] + var previousAddress: String? for addressType in ["Legacy (P2PKH)", "Nested SegWit (P2SH-P2WPKH)", "Native SegWit (P2WPKH)", "Taproot (P2TR)"] { selectAddressType(addressType) - approveOnEmulator(for: 10) + approveOnEmulator(for: 30) app.buttons["TrezorGenerateAddress"].tap() - let address = readStaticText("TrezorGeneratedAddress", timeout: 20) + let address = readStaticText("TrezorGeneratedAddress", timeout: 45, previousValue: previousAddress) + stopApprovingOnEmulator() XCTAssertFalse(address.isEmpty) addresses[addressType] = address + previousAddress = address } - XCTAssertTrue(addresses["Legacy (P2PKH)"]?.hasPrefix("m") == true || addresses["Legacy (P2PKH)"]?.hasPrefix("n") == true) - XCTAssertTrue(addresses["Nested SegWit (P2SH-P2WPKH)"]?.hasPrefix("2") == true) - XCTAssertTrue(addresses["Native SegWit (P2WPKH)"]?.hasPrefix("bcrt1q") == true) - XCTAssertTrue(addresses["Taproot (P2TR)"]?.hasPrefix("bcrt1p") == true) + XCTAssertTrue( + addresses["Legacy (P2PKH)"]?.hasPrefix("m") == true || addresses["Legacy (P2PKH)"]?.hasPrefix("n") == true, + "Unexpected legacy address: \(addresses["Legacy (P2PKH)"] ?? "")" + ) + XCTAssertTrue( + addresses["Nested SegWit (P2SH-P2WPKH)"]?.hasPrefix("2") == true, + "Unexpected nested SegWit address: \(addresses["Nested SegWit (P2SH-P2WPKH)"] ?? "")" + ) + XCTAssertTrue( + addresses["Native SegWit (P2WPKH)"]?.hasPrefix("bcrt1q") == true, + "Unexpected native SegWit address: \(addresses["Native SegWit (P2WPKH)"] ?? "")" + ) + XCTAssertTrue( + addresses["Taproot (P2TR)"]?.hasPrefix("bcrt1p") == true, + "Unexpected Taproot address: \(addresses["Taproot (P2TR)"] ?? "")" + ) XCTAssertTrue(app.images["TrezorGeneratedAddressQr"].exists || app.otherElements["TrezorGeneratedAddressQr"].exists) return addresses @@ -160,9 +207,11 @@ final class TrezorBridgeDashboardUITests: XCTestCase { } selectAddressType("Native SegWit (P2WPKH)") app.buttons["TrezorAddressIndexIncrement"].tap() - approveOnEmulator(for: 10) + let previousAddress = app.staticTexts["TrezorGeneratedAddress"].exists ? app.staticTexts["TrezorGeneratedAddress"].label : nil + approveOnEmulator(for: 30) app.buttons["TrezorGenerateAddress"].tap() - let address = readStaticText("TrezorGeneratedAddress", timeout: 20) + let address = readStaticText("TrezorGeneratedAddress", timeout: 45, previousValue: previousAddress) + stopApprovingOnEmulator() XCTAssertTrue(address.hasPrefix("bcrt1q")) return address } @@ -179,9 +228,10 @@ final class TrezorBridgeDashboardUITests: XCTestCase { private func exportPublicKey() throws -> String { app.buttons["TrezorSection-PublicKey"].tap() - approveOnEmulator(for: 10) - app.buttons["TrezorPublicKeyGet"].tap() - let xpub = readStaticText("TrezorXpub", timeout: 20) + let getButton = app.buttons["TrezorPublicKeyGet"] + scrollTo(getButton) + getButton.tap() + let xpub = readStaticText("TrezorXpub", timeout: 45) XCTAssertTrue(xpub.hasPrefix("tpub") || xpub.hasPrefix("vpub") || xpub.hasPrefix("xpub") || xpub.hasPrefix("zpub")) XCTAssertFalse(readStaticText("TrezorPublicKeyHex", timeout: 5).isEmpty) return xpub @@ -190,24 +240,38 @@ final class TrezorBridgeDashboardUITests: XCTestCase { private func signAndVerifyMessage() throws { app.buttons["TrezorSection-SignMessage"].tap() clearAndType(app.textFields["TrezorMessageToSign"], text: "Bitkit Trezor emulator test") - approveOnEmulator(for: 15) + approveOnEmulator(for: 90) app.buttons["TrezorSignMessageButton"].tap() - let signature = readStaticText("TrezorSignature", timeout: 20) + let signature = readStaticText("TrezorSignature", timeout: 90) + stopApprovingOnEmulator() let address = readStaticText("TrezorSignedMessageAddress", timeout: 5) XCTAssertFalse(signature.isEmpty) XCTAssertFalse(address.isEmpty) app.segmentedControls["TrezorSignMessageMode"].buttons["Verify"].tap() - clearAndType(app.textFields["TrezorVerifyAddress"], text: address) - clearAndType(app.textFields["TrezorVerifySignature"], text: signature) - clearAndType(app.textFields["TrezorVerifyMessage"], text: "Bitkit Trezor emulator test") + XCTAssertTrue(app.textFields["TrezorVerifyAddress"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.textFields["TrezorVerifyAddress"].value as? String, address) + XCTAssertFalse((app.textFields["TrezorVerifySignature"].value as? String ?? "").isEmpty) + XCTAssertEqual(app.textFields["TrezorVerifyMessage"].value as? String, "Bitkit Trezor emulator test") + scrollTo(app.buttons["TrezorVerifySignatureButton"]) + approveOnEmulator(for: 90) app.buttons["TrezorVerifySignatureButton"].tap() - XCTAssertTrue(app.otherElements["TrezorSignatureValid"].waitForExistence(timeout: 10)) + XCTAssertTrue( + app.otherElements["TrezorSignatureValid"].waitForExistence(timeout: 90), + "Expected valid signature verification. Accessibility tree: \(app.debugDescription)" + ) + stopApprovingOnEmulator() clearAndType(app.textFields["TrezorVerifyMessage"], text: "Tampered Bitkit Trezor emulator test") + scrollTo(app.buttons["TrezorVerifySignatureButton"]) + approveOnEmulator(for: 90) app.buttons["TrezorVerifySignatureButton"].tap() - XCTAssertTrue(app.otherElements["TrezorSignatureInvalid"].waitForExistence(timeout: 10)) + XCTAssertTrue( + app.otherElements["TrezorSignatureInvalid"].waitForExistence(timeout: 90), + "Expected tampered message verification to fail. Accessibility tree: \(app.debugDescription)" + ) + stopApprovingOnEmulator() } private func lookupBalanceHistoryAndDetail(xpub: String, address: String) throws { @@ -224,7 +288,7 @@ final class TrezorBridgeDashboardUITests: XCTestCase { app.buttons["TrezorSection-TxHistory"].tap() clearAndType(app.textFields["TrezorTxHistoryInput"], text: xpub) app.buttons["TrezorTxHistoryButton"].tap() - let txRow = app.otherElements["TrezorTxHistoryRow"] + let txRow = app.otherElements.matching(identifier: "TrezorTxHistoryRow").firstMatch XCTAssertTrue(txRow.waitForExistence(timeout: 30)) let txid = txRow.value as? String ?? txRow.label XCTAssertFalse(txid.isEmpty) @@ -252,9 +316,10 @@ final class TrezorBridgeDashboardUITests: XCTestCase { app.buttons["TrezorComposeButton"].tap() XCTAssertTrue(app.otherElements["TrezorComposeReview"].waitForExistence(timeout: 30)) - approveOnEmulator(for: 30) + approveOnEmulator(for: 120) app.buttons["TrezorSignTxButton"].tap() - XCTAssertTrue(app.otherElements["TrezorSignedTxResult"].waitForExistence(timeout: 40)) + XCTAssertTrue(app.otherElements["TrezorSignedTxResult"].waitForExistence(timeout: 120)) + stopApprovingOnEmulator() app.buttons["TrezorBroadcastButton"].tap() XCTAssertTrue(app.otherElements["TrezorBroadcastResult"].waitForExistence(timeout: 30)) @@ -262,19 +327,102 @@ final class TrezorBridgeDashboardUITests: XCTestCase { } private func approveOnEmulator(for seconds: TimeInterval) { - let deadline = Date().addingTimeInterval(seconds) - DispatchQueue.global(qos: .userInitiated).async { [userEnv] in - while Date() < deadline { - try? userEnv.send(type: "emulator-press-yes") - Thread.sleep(forTimeInterval: 0.4) + approvalLock.lock() + approvalDeadline = max(approvalDeadline, Date().addingTimeInterval(seconds)) + let shouldStart = !isApprovalLoopRunning + if shouldStart { + isApprovalLoopRunning = true + } + approvalLock.unlock() + + guard shouldStart else { return } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.runApprovalLoop() + } + } + + private func stopApprovingOnEmulator() { + approvalLock.lock() + approvalDeadline = Date() + approvalLock.unlock() + } + + private func runApprovalLoop() { + while true { + approvalLock.lock() + let shouldContinue = Date() < approvalDeadline + if !shouldContinue { + isApprovalLoopRunning = false + approvalLock.unlock() + return + } + approvalLock.unlock() + + try? userEnv.send(type: "emulator-press-yes") + Thread.sleep(forTimeInterval: 1.0) + } + } + + private func releaseBridgeSessionIfAny() { + guard let enumerateUrl = URL(string: "http://127.0.0.1:21325/enumerate"), + let enumerateData = try? post(enumerateUrl), + let devices = try? JSONDecoder().decode([BridgeDevice].self, from: enumerateData) + else { + return + } + + for device in devices { + guard let session = device.session, + let releaseUrl = URL(string: "http://127.0.0.1:21325/release/\(session)") + else { + continue } + _ = try? post(releaseUrl) } } - private func readStaticText(_ identifier: String, timeout: TimeInterval) -> String { + private func post(_ url: URL) throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 5 + + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + URLSession(configuration: .ephemeral).dataTask(with: request) { data, _, error in + defer { semaphore.signal() } + if let error { + result = .failure(error) + } else { + result = .success(data ?? Data()) + } + }.resume() + + semaphore.wait() + return try result?.get() ?? Data() + } + + private func readStaticText(_ identifier: String, timeout: TimeInterval, previousValue: String? = nil) -> String { let element = app.staticTexts[identifier] - XCTAssertTrue(element.waitForExistence(timeout: timeout), "Missing static text \(identifier)") - return element.label + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element.exists { + let value = element.label + if !value.isEmpty, value != previousValue { + scrollTo(element) + return value + } + } else { + app.swipeUp() + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + XCTFail( + "Missing or unchanged static text \(identifier). Previous value: \(previousValue ?? ""). Accessibility tree: \(app.debugDescription)" + ) + return element.exists ? element.label : "" } private func clearAndType(_ element: XCUIElement, text: String) { @@ -297,6 +445,10 @@ final class TrezorBridgeDashboardUITests: XCTestCase { } } +private struct BridgeDevice: Decodable { + let session: String? +} + private final class TrezorUserEnvController { private let url = URL(string: "ws://127.0.0.1:9001")! private var nextId = 0 diff --git a/Docs/AI_DEVICE_TESTS.md b/Docs/AI_DEVICE_TESTS.md index fd46fa5b5..4b6fa7974 100644 --- a/Docs/AI_DEVICE_TESTS.md +++ b/Docs/AI_DEVICE_TESTS.md @@ -16,6 +16,7 @@ Run the iOS suite from this repository: ```bash 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 \ @@ -26,7 +27,7 @@ xcodebuild test \ -configuration Debug \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -derivedDataPath DerivedData \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ -parallel-testing-enabled NO ``` From ecfef5faa25fb182d43d9616c4b624102cb585ea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 22 May 2026 03:14:45 +0200 Subject: [PATCH 4/4] test: read rendered Trezor debug log entries --- Bitkit/Views/Trezor/TrezorRootView.swift | 1 + BitkitUITests/TrezorBridgeDashboardUITests.swift | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index 0c228c3c0..0a661ee66 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -568,6 +568,7 @@ struct TrezorDebugLogPanel: View { Text(entry) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) + .accessibilityIdentifier("TrezorDebugLogEntry") .id(index) } } diff --git a/BitkitUITests/TrezorBridgeDashboardUITests.swift b/BitkitUITests/TrezorBridgeDashboardUITests.swift index b11383a87..c670f99cb 100644 --- a/BitkitUITests/TrezorBridgeDashboardUITests.swift +++ b/BitkitUITests/TrezorBridgeDashboardUITests.swift @@ -99,7 +99,12 @@ final class TrezorBridgeDashboardUITests: XCTestCase { app.buttons["TrezorDebugLogToggle"].tap() let debugEntries = app.otherElements["TrezorDebugLogEntries"] XCTAssertTrue(debugEntries.waitForExistence(timeout: 5)) - let debugText = debugEntries.label.lowercased() + let debugLogEntries = app.staticTexts.matching(identifier: "TrezorDebugLogEntry") + XCTAssertGreaterThan(debugLogEntries.count, 0, "Expected visible debug log entries before checking redaction") + let debugText = (0 ..< debugLogEntries.count) + .map { debugLogEntries.element(boundBy: $0).label } + .joined(separator: "\n") + .lowercased() XCTAssertFalse(debugText.contains("all all all all"), "Debug log must not expose the deterministic mnemonic") XCTAssertFalse(debugText.contains("passphrase"), "Debug log must not expose passphrase payloads") }