diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml new file mode 100644 index 000000000..bc9a4a027 --- /dev/null +++ b/.github/workflows/ai-device-tests.yml @@ -0,0 +1,135 @@ +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" + TEST_TREZOR_RESET_STATE: "1" + TREZOR_BRIDGE: "true" + TREZOR_BRIDGE_URL: "http://127.0.0.1:21325" + TREZOR_ELECTRUM_URL: "tcp://127.0.0.1:60001" + E2E: "true" + E2E_BACKEND: "local" + E2E_NETWORK: "regtest" + GEO: "false" + SIMULATOR_NAME: ${{ inputs.simulator_name }} + SIMULATOR_OS: ${{ inputs.simulator_os }} + run: | + mkdir -p TestResults + set -o pipefail + xcodebuild test \ + -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme BitkitAITests \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$SIMULATOR_OS" \ + -derivedDataPath DerivedData \ + -resultBundlePath TestResults/TrezorBridgeDashboardUITests.xcresult \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ + -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ + -parallel-testing-enabled NO \ + | 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/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.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/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 1b5fec686..7c8feacfc 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 { @@ -137,6 +138,7 @@ struct KnownDeviceRow: View { } .buttonStyle(.plain) .disabled(isConnecting) + .accessibilityIdentifier("TrezorKnownDeviceConnect-\(accessibilitySuffix)") // Forget button Button(action: onForget) { @@ -146,10 +148,16 @@ struct KnownDeviceRow: View { .padding(10) } .buttonStyle(.plain) + .accessibilityIdentifier("TrezorForgetDevice-\(accessibilitySuffix)") } .padding(16) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .trezorAccessibilityAnchor("TrezorKnownDevice-\(accessibilitySuffix)") + } + + private var accessibilitySuffix: String { + device.path.hasPrefix("bridge:") ? "bridge" : 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..ff3ebeb6c 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)) + .trezorAccessibilityAnchor("TrezorConfirmOnDeviceOverlay") } } @@ -118,6 +120,7 @@ struct TrezorErrorBanner: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .trezorAccessibilityAnchor("TrezorErrorBanner") } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 63f2bb042..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) @@ -166,6 +190,18 @@ enum Env { return configValue("TREZOR_ELECTRUM_URL") } + static var isTrezorEmulatorTesting: Bool { + #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 { // App group so files can be shared with extensions guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else { 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 b816dc4b0..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..." @@ -1326,4 +1337,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..95537e0e7 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)) + .trezorAccessibilityAnchor("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)) + .trezorAccessibilityAnchor("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..2e551f544 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)) } + .trezorAccessibilityAnchor("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)) } + .trezorAccessibilityAnchor("TrezorAddressLookupResult") } } @@ -327,6 +337,7 @@ private struct ResultRow: View { Spacer() } + .accessibilityIdentifier("TrezorResultRow-\(label.replacingOccurrences(of: " ", with: ""))") } } @@ -348,6 +359,7 @@ private struct UTXOListSection: View { } } } + .trezorAccessibilityAnchor("TrezorUtxoList") } } } @@ -432,6 +444,7 @@ private struct UTXORow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .trezorAccessibilityAnchor("TrezorUtxoRow") } private var truncatedTxid: String { diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index f66d84f59..94c805698 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) + .trezorAccessibilityAnchor("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)) + .trezorAccessibilityAnchor("TrezorDeviceInfoCard") } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift index 0fc6e8aa0..322f4ef93 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift @@ -16,6 +16,7 @@ struct TrezorDeviceFeaturesContent: View { NoFeaturesView() } } + .trezorAccessibilityAnchor("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) + .trezorAccessibilityAnchor("TrezorNoFeatures") } } @@ -205,6 +208,7 @@ private struct InfoSection: View { .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .trezorAccessibilityAnchor("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..7eeafad35 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) + .trezorAccessibilityAnchor("TrezorDeviceList") .task { trezor.loadKnownDevices() @@ -184,6 +186,7 @@ private struct AutoReconnectIndicator: View { .foregroundColor(.white.opacity(0.6)) } .padding(16) + .trezorAccessibilityAnchor("TrezorAutoReconnectIndicator") } } @@ -209,6 +212,7 @@ private struct BluetoothStatusCard: View { .frame(maxWidth: .infinity) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 16)) + .trezorAccessibilityAnchor("TrezorBluetoothStatus") } private var statusTitle: String { @@ -255,6 +259,7 @@ private struct ScanningIndicator: View { .multilineTextAlignment(.center) } .padding(32) + .trezorAccessibilityAnchor("TrezorScanningIndicator") } } @@ -277,6 +282,7 @@ private struct TrezorEmptyStateView: View { } } .padding(32) + .trezorAccessibilityAnchor("TrezorEmptyState") } } @@ -297,6 +303,7 @@ private struct ErrorCard: View { .padding(16) .background(Color.red.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .trezorAccessibilityAnchor("TrezorDeviceListError") } } diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift index 0842b3068..cb51ae12e 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)) + .trezorAccessibilityAnchor("\(accessibilityIdentifier)Card") } } diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift index d7685c3f3..0a661ee66 100644 --- a/Bitkit/Views/Trezor/TrezorRootView.swift +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -7,21 +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 { + TrezorAccessibilityAnchor(id: "TrezorRoot") + } + + VStack(spacing: 0) { + NetworkSelectorRow() + + if Env.isTrezorEmulatorTesting { + TrezorEmulatorTestHooksView() + } - ZStack(alignment: .bottom) { - TrezorContentSwitcher() - .frame(maxHeight: .infinity) - .padding(.bottom, 40) + ZStack(alignment: .bottom) { + TrezorContentSwitcher() + .frame(maxHeight: .infinity) + .padding(.bottom, 40) - TrezorDebugLogWrapper() + TrezorDebugLogWrapper() + } } } .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 @@ -119,6 +148,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 +201,7 @@ struct TrezorPinEntrySheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPinCancel") Button(action: { trezor.submitPin(pin) @@ -186,6 +217,7 @@ struct TrezorPinEntrySheet: View { } .disabled(pin.isEmpty) .opacity(pin.isEmpty ? 0.5 : 1.0) + .accessibilityIdentifier("TrezorPinConfirm") } .padding(.horizontal, 16) } @@ -193,6 +225,7 @@ struct TrezorPinEntrySheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .trezorAccessibilityAnchor("TrezorPinSheet") } } @@ -242,6 +275,7 @@ struct TrezorPairingCodeSheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPairingCancel") Button(action: { guard !hasSubmitted else { return } @@ -259,6 +293,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 +301,7 @@ struct TrezorPairingCodeSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .trezorAccessibilityAnchor("TrezorPairingSheet") .onChange(of: code) { newValue in if newValue.count == digitCount { guard !hasSubmitted else { return } @@ -364,6 +400,7 @@ struct TrezorPassphraseSheet: View { .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } + .accessibilityIdentifier("TrezorPassphraseCancel") Button(action: { trezor.submitPassphrase(passphrase) @@ -379,6 +416,7 @@ struct TrezorPassphraseSheet: View { } .disabled(!isValid) .opacity(isValid ? 1.0 : 0.5) + .accessibilityIdentifier("TrezorPassphraseConfirm") } .padding(.horizontal, 16) } @@ -386,6 +424,7 @@ struct TrezorPassphraseSheet: View { .padding(.bottom, 16) .background(Color.black) .interactiveDismissDisabled() + .trezorAccessibilityAnchor("TrezorPassphraseSheet") .task { focusedField = .passphrase } @@ -411,6 +450,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)) + .trezorAccessibilityAnchor("TrezorTestHooks") } } @@ -449,6 +525,7 @@ struct TrezorDebugLogPanel: View { .padding(.vertical, 10) } .background(Color.white.opacity(0.05)) + .accessibilityIdentifier("TrezorDebugLogToggle") // Expanded content if isExpanded { @@ -466,6 +543,7 @@ struct TrezorDebugLogPanel: View { .font(.system(size: 11)) .foregroundColor(.white.opacity(0.5)) } + .accessibilityIdentifier("TrezorDebugLogCopy") Button(action: { debugLog.clear() }) { HStack(spacing: 4) { @@ -475,6 +553,7 @@ struct TrezorDebugLogPanel: View { .font(.system(size: 11)) .foregroundColor(.white.opacity(0.5)) } + .accessibilityIdentifier("TrezorDebugLogClear") Spacer() } @@ -489,12 +568,14 @@ struct TrezorDebugLogPanel: View { Text(entry) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) + .accessibilityIdentifier("TrezorDebugLogEntry") .id(index) } } .padding(.horizontal, 16) } .frame(maxHeight: 300) + .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 c654ebbc4..3a4ad23b2 100644 --- a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift +++ b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift @@ -74,6 +74,7 @@ struct SendTransactionSection: View { TrezorErrorBanner(message: sendError) } } + .trezorAccessibilityAnchor("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)) } } + .trezorAccessibilityAnchor("TrezorComposeReview") } } } @@ -345,6 +355,7 @@ private struct PSBTPreview: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .trezorAccessibilityAnchor("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") } + .trezorAccessibilityAnchor("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)) + .trezorAccessibilityAnchor("TrezorBroadcastResult") } } diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift index fefba4183..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 = "" @@ -24,6 +25,7 @@ struct TrezorSignMessageContent: View { } } .pickerStyle(.segmented) + .accessibilityIdentifier("TrezorSignMessageMode") switch selectedTab { case .sign: @@ -37,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 + } } } @@ -77,6 +86,7 @@ private struct SignMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorMessageSigningPath") } // Message input @@ -93,6 +103,7 @@ private struct SignMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorMessageToSign") } // Sign button @@ -120,6 +131,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 +181,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifyAddress") } // Signature input @@ -185,6 +198,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifySignature") } // Message input @@ -201,6 +215,7 @@ private struct VerifyMessageContent: View { .padding(12) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("TrezorVerifyMessage") } // Verify button @@ -232,6 +247,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 +296,7 @@ private struct SignedMessageResult: View { Text(response.address) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.white) + .accessibilityIdentifier("TrezorSignedMessageAddress") } // Signature @@ -292,6 +309,7 @@ private struct SignedMessageResult: View { .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white) .lineLimit(3) + .accessibilityIdentifier("TrezorSignature") } // Copy button @@ -299,7 +317,6 @@ private struct SignedMessageResult: View { UIPasteboard.general.string = response.signature copiedSignature = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copiedSignature = false } @@ -311,11 +328,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)) + .trezorAccessibilityAnchor("TrezorSignedMessageResult") } } @@ -338,6 +357,7 @@ private struct VerificationResultBanner: View { .padding(16) .background((isValid ? Color.green : Color.red).opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) + .trezorAccessibilityAnchor(isValid ? "TrezorSignatureValid" : "TrezorSignatureInvalid") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift b/Bitkit/Views/Trezor/TrezorTransactionDetailView.swift index 239b59090..7e89419fb 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)) } + .trezorAccessibilityAnchor("TrezorTxDetailOverview") } private var directionLabel: String { @@ -321,6 +330,7 @@ private struct TxDetailInputsSection: View { } } } + .trezorAccessibilityAnchor("TrezorTxDetailInputs") } } } @@ -395,6 +405,7 @@ private struct TxInputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .trezorAccessibilityAnchor("TrezorTxDetailInputRow") } private var truncatedTxid: String { @@ -424,6 +435,7 @@ private struct TxDetailOutputsSection: View { } } } + .trezorAccessibilityAnchor("TrezorTxDetailOutputs") } } } @@ -477,6 +489,7 @@ private struct TxOutputRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .trezorAccessibilityAnchor("TrezorTxDetailOutputRow") } } diff --git a/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift b/Bitkit/Views/Trezor/TrezorTransactionHistoryView.swift index 4db528ea8..a43067baf 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)) } + .trezorAccessibilityAnchor("TrezorTxHistorySummary") } private func accountTypeLabel(_ type: AccountType) -> String { @@ -215,6 +220,7 @@ private struct TxHistoryListSection: View { } } } + .trezorAccessibilityAnchor("TrezorTxHistoryList") } } } @@ -314,6 +320,8 @@ private struct TxHistoryRow: View { .padding(12) .background(Color.white.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .trezorAccessibilityAnchor("TrezorTxHistoryRow") + .accessibilityValue(tx.txid) } private var directionIcon: String { diff --git a/BitkitUITests/TrezorBridgeDashboardUITests.swift b/BitkitUITests/TrezorBridgeDashboardUITests.swift new file mode 100644 index 000000000..c670f99cb --- /dev/null +++ b/BitkitUITests/TrezorBridgeDashboardUITests.swift @@ -0,0 +1,595 @@ +import Foundation +import UIKit +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 + #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() + } + + 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() + app.buttons["TrezorDebugLogToggle"].tap() + + scrollTo(app.buttons["TrezorDisconnectButton"]) + app.buttons["TrezorDisconnectButton"].tap() + 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)) + } + + 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 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") + } + + 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", + "TEST_TREZOR_RESET_STATE": "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.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() + let root = app.otherElements["TrezorRoot"] + if !root.waitForExistence(timeout: 20) { + XCTFail("TrezorRoot did not appear. Accessibility tree: \(app.debugDescription)") + } + } + + 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)) + + bridgeDevice.tap() + 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: 30) + app.buttons["TrezorGenerateAddress"].tap() + 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, + "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 + } + + private func generateNextNativeSegwitAddress() throws -> String { + if !app.buttons["TrezorGenerateAddress"].exists { + app.buttons["TrezorSection-Address"].tap() + } + selectAddressType("Native SegWit (P2WPKH)") + app.buttons["TrezorAddressIndexIncrement"].tap() + let previousAddress = app.staticTexts["TrezorGeneratedAddress"].exists ? app.staticTexts["TrezorGeneratedAddress"].label : nil + approveOnEmulator(for: 30) + app.buttons["TrezorGenerateAddress"].tap() + let address = readStaticText("TrezorGeneratedAddress", timeout: 45, previousValue: previousAddress) + stopApprovingOnEmulator() + 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() + 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 + } + + private func signAndVerifyMessage() throws { + app.buttons["TrezorSection-SignMessage"].tap() + clearAndType(app.textFields["TrezorMessageToSign"], text: "Bitkit Trezor emulator test") + approveOnEmulator(for: 90) + app.buttons["TrezorSignMessageButton"].tap() + + 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() + 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: 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: 90), + "Expected tampered message verification to fail. Accessibility tree: \(app.debugDescription)" + ) + stopApprovingOnEmulator() + } + + 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.matching(identifier: "TrezorTxHistoryRow").firstMatch + 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: 120) + app.buttons["TrezorSignTxButton"].tap() + XCTAssertTrue(app.otherElements["TrezorSignedTxResult"].waitForExistence(timeout: 120)) + stopApprovingOnEmulator() + + app.buttons["TrezorBroadcastButton"].tap() + XCTAssertTrue(app.otherElements["TrezorBroadcastResult"].waitForExistence(timeout: 30)) + XCTAssertFalse(readStaticText("TrezorBroadcastTxid", timeout: 5).isEmpty) + } + + private func approveOnEmulator(for seconds: TimeInterval) { + 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 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] + 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) { + 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 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 + + 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..4b6fa7974 --- /dev/null +++ b/Docs/AI_DEVICE_TESTS.md @@ -0,0 +1,41 @@ +# 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 \ +TEST_TREZOR_RESET_STATE=1 \ +TREZOR_BRIDGE=true \ +TREZOR_BRIDGE_URL=http://127.0.0.1:21325 \ +TREZOR_ELECTRUM_URL=tcp://127.0.0.1:60001 \ +E2E=true E2E_BACKEND=local E2E_NETWORK=regtest GEO=false \ +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 TEST_TREZOR_EMU' \ + -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`. + +## 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.