diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6233c6f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Tests/Goldens/** whitespace=-blank-at-eol,-blank-at-eof diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 5ab70a4..bfe7352 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -28,10 +28,19 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.2' + xcode-version: '26.5' + + - name: Resolve Xcode cache identity + id: xcode_identity + run: | + set -euo pipefail + XCODE_VERSION=$(xcodebuild -version | awk 'NR == 1 { print $2 }') + XCODE_BUILD=$(xcodebuild -version | awk 'NR == 2 { print $3 }') + CACHE_IDENTITY=$(printf 'xcode-%s-%s' "$XCODE_VERSION" "$XCODE_BUILD" | tr -c 'A-Za-z0-9._-' '-') + echo "cache_identity=$CACHE_IDENTITY" >> "$GITHUB_OUTPUT" - name: Install dependencies - run: brew install jq + run: brew install jq xcodegen - name: Make release scripts executable run: | @@ -56,27 +65,38 @@ jobs: id: idb_inputs run: | set -euo pipefail + PINNED_IDB_URL=$(grep '^IDB_GIT_URL=' scripts/build.sh | sed -E 's/^IDB_GIT_URL="\$\{IDB_GIT_URL:-([^}]*)\}"$/\1/') PINNED_IDB_COMMIT=$(grep '^IDB_GIT_REF=' scripts/build.sh | sed -E 's/^IDB_GIT_REF="\$\{IDB_GIT_REF:-([^}]*)\}"$/\1/') - PATCH_HASH=$(find patches/idb -name '*.patch' -type f | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + PINNED_IDB_UPSTREAM_BASE=$(grep '^IDB_UPSTREAM_BASE_REF=' scripts/build.sh | sed -E 's/^IDB_UPSTREAM_BASE_REF="\$\{IDB_UPSTREAM_BASE_REF:-([^}]*)\}"$/\1/') + SOURCE_IDENTITY=$(printf '%s\n%s\n%s\n' "$PINNED_IDB_URL" "$PINNED_IDB_COMMIT" "$PINNED_IDB_UPSTREAM_BASE" | shasum -a 256 | awk '{print $1}') + BUILD_PIPELINE_HASH=$(printf '%s\n%s\n' "$(shasum -a 256 scripts/build.sh Package.swift)" "$(xcodegen --version)" | shasum -a 256 | awk '{print $1}') + echo "idb_url=$PINNED_IDB_URL" >> "$GITHUB_OUTPUT" echo "idb_commit=$PINNED_IDB_COMMIT" >> "$GITHUB_OUTPUT" - echo "patch_hash=$PATCH_HASH" >> "$GITHUB_OUTPUT" + echo "idb_upstream_base=$PINNED_IDB_UPSTREAM_BASE" >> "$GITHUB_OUTPUT" + echo "source_identity=$SOURCE_IDENTITY" >> "$GITHUB_OUTPUT" + echo "build_pipeline_hash=$BUILD_PIPELINE_HASH" >> "$GITHUB_OUTPUT" - name: Restore IDB repository cache uses: actions/cache/restore@v4 with: path: idb_checkout - key: idb-repo-${{ runner.os }}-${{ steps.idb_inputs.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: idb-repo-${{ runner.os }}-${{ steps.idb_inputs.outputs.source_identity }} - name: Check IDB repository freshness id: idb_check run: | set -euo pipefail + PINNED_IDB_URL="${{ steps.idb_inputs.outputs.idb_url }}" PINNED_IDB_COMMIT="${{ steps.idb_inputs.outputs.idb_commit }}" - echo "idb_commit=$PINNED_IDB_COMMIT" >> "$GITHUB_OUTPUT" + PINNED_IDB_UPSTREAM_BASE="${{ steps.idb_inputs.outputs.idb_upstream_base }}" if [ -d "idb_checkout/.git" ]; then LOCAL_COMMIT=$(git -C idb_checkout rev-parse HEAD) - if [ "$LOCAL_COMMIT" = "$PINNED_IDB_COMMIT" ]; then + LOCAL_REMOTE=$(git -C idb_checkout remote get-url origin) + if [ "$LOCAL_COMMIT" = "$PINNED_IDB_COMMIT" ] \ + && [ "$LOCAL_REMOTE" = "$PINNED_IDB_URL" ] \ + && git -C idb_checkout merge-base --is-ancestor "$PINNED_IDB_UPSTREAM_BASE" "$LOCAL_COMMIT" \ + && [ -z "$(git -C idb_checkout status --porcelain)" ]; then echo "needs_setup=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -88,19 +108,23 @@ jobs: uses: actions/cache/restore@v4 with: path: build_products/Frameworks - key: frameworks-${{ runner.os }}-${{ steps.idb_check.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: frameworks-${{ runner.os }}-${{ steps.xcode_identity.outputs.cache_identity }}-${{ steps.idb_inputs.outputs.source_identity }}-${{ steps.idb_inputs.outputs.build_pipeline_hash }} - name: Restore XCFramework cache uses: actions/cache/restore@v4 with: path: build_products/XCFrameworks - key: xcframeworks-${{ runner.os }}-${{ steps.idb_check.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: xcframeworks-${{ runner.os }}-${{ steps.xcode_identity.outputs.cache_identity }}-${{ steps.idb_inputs.outputs.source_identity }}-${{ steps.idb_inputs.outputs.build_pipeline_hash }} - name: Check XCFramework outputs id: xcframework_check run: | set -euo pipefail REQUIRED=( + "build_products/Frameworks/FBControlCore.framework" + "build_products/Frameworks/XCTestBootstrap.framework" + "build_products/Frameworks/FBSimulatorControl.framework" + "build_products/Frameworks/FBDeviceControl.framework" "build_products/XCFrameworks/FBControlCore.xcframework" "build_products/XCFrameworks/XCTestBootstrap.xcframework" "build_products/XCFrameworks/FBSimulatorControl.xcframework" @@ -114,7 +138,12 @@ jobs: fi done - echo "needs_build=false" >> "$GITHUB_OUTPUT" + if scripts/build.sh verify-xcframeworks >/dev/null 2>&1; then + echo "needs_build=false" >> "$GITHUB_OUTPUT" + else + echo "Cached XCFramework verification failed; rebuilding framework artifacts" + echo "needs_build=true" >> "$GITHUB_OUTPUT" + fi - name: Setup - Clone IDB repository if: steps.idb_check.outputs.needs_setup == 'true' @@ -192,7 +221,7 @@ jobs: echo "$NOTARIZE_APP_STORE_CONNECT_API_KEY" | base64 --decode > keys/AuthKey_${{ secrets.NOTARIZE_APP_STORE_CONNECT_API_KEY_ID }}.p8 - name: Sign - Framework binaries - if: steps.signing.outputs.has_certificate == 'true' && (steps.idb_check.outputs.needs_setup == 'true' || steps.xcframework_check.outputs.needs_build == 'true') + if: steps.signing.outputs.has_certificate == 'true' run: scripts/build.sh sign-frameworks - name: Create - XCFrameworks @@ -200,7 +229,7 @@ jobs: run: scripts/build.sh xcframeworks - name: Sign - XCFramework bundles - if: steps.signing.outputs.has_certificate == 'true' && (steps.idb_check.outputs.needs_setup == 'true' || steps.xcframework_check.outputs.needs_build == 'true') + if: steps.signing.outputs.has_certificate == 'true' run: scripts/build.sh sign-xcframeworks - name: Save IDB repository cache @@ -208,21 +237,21 @@ jobs: uses: actions/cache/save@v4 with: path: idb_checkout - key: idb-repo-${{ runner.os }}-${{ steps.idb_check.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: idb-repo-${{ runner.os }}-${{ steps.idb_inputs.outputs.source_identity }} - name: Save runtime Frameworks cache if: steps.idb_check.outputs.needs_setup == 'true' || steps.xcframework_check.outputs.needs_build == 'true' uses: actions/cache/save@v4 with: path: build_products/Frameworks - key: frameworks-${{ runner.os }}-${{ steps.idb_check.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: frameworks-${{ runner.os }}-${{ steps.xcode_identity.outputs.cache_identity }}-${{ steps.idb_inputs.outputs.source_identity }}-${{ steps.idb_inputs.outputs.build_pipeline_hash }} - name: Save XCFramework cache if: steps.idb_check.outputs.needs_setup == 'true' || steps.xcframework_check.outputs.needs_build == 'true' uses: actions/cache/save@v4 with: path: build_products/XCFrameworks - key: xcframeworks-${{ runner.os }}-${{ steps.idb_check.outputs.idb_commit }}-${{ steps.idb_inputs.outputs.patch_hash }} + key: xcframeworks-${{ runner.os }}-${{ steps.xcode_identity.outputs.cache_identity }}-${{ steps.idb_inputs.outputs.source_identity }}-${{ steps.idb_inputs.outputs.build_pipeline_hash }} - name: Prepare - Clean Swift build environment run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ef73718 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift-tests: + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install build dependencies + run: brew install xcodegen + + - name: Run Swift tests + run: ./test-runner.sh --unit-tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 4761522..978a7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Changed the IDB integration to consume an immutable full-SHA-pinned AXe fork instead of applying local patches during builds. +- Changed build and release validation to record Xcode build identifiers as provenance instead of requiring exact toolchain and runtime builds. +- Changed BGRA video streams to honor the requested `--fps` value consistently with the other stream formats instead of using an uncapped native frame rate. + ### Fixed -- Fixed SimulatorKit loading with Xcode 27 beta by checking Xcode's `Contents/SharedFrameworks` layout before falling back to the legacy private-framework path. +- Fixed IDB framework builds failing on incomplete generated checkouts by replacing the cache with a clean clone. +- Fixed `make e2e` under Xcode 27 by rebuilding IDB frameworks with the selected toolchain, selecting the matching iOS 27 simulator, and launching Device Hub before running tests. +- Fixed simulator automation with Xcode 27 Beta 3 (27A5218g) while retaining Xcode 26 support by updating the IDB integration, selecting the compatible HID transport, and bootstrapping accessibility through Xcode's current private-framework path. - Fixed `test-runner.sh` loading AXe's binary target frameworks with newer SwiftPM layouts while remaining compatible with Xcode 26. - Fixed E2E test hangs by resolving the `axe` executable path before `swift test` starts instead of invoking SwiftPM from inside test cases. +- Fixed release archives embedding AppleDouble (`._*`) metadata files that broke strict Gatekeeper verification of the bundled frameworks by sanitizing staged payloads and excluding filesystem metadata from zip and tar packaging. +- Fixed `build.sh` blocking on an interactive git pager during IDB checkout verification when run in a terminal. +- Fixed Xcode 26 E2E setup and managed IDB cache recovery, including incomplete custom `IDB_CHECKOUT_DIR` checkouts created by AXe. +- Fixed HID broker crash and simulator-reboot recovery so concurrent cold starts spawn one broker, stale sessions are replaced before sending the first touch request, ambiguous touch requests are never replayed, and broker identity follows the selected Xcode. +- Fixed runtime failures printing raw error implementation details and internal simulator terminology instead of actionable, user-facing messages. +- Fixed transient Xcode 27 accessibility point lookups returning the screen root instead of the targeted element. +- Fixed E2E fixture relaunches racing Device Hub by verifying the requested screen before interacting. ## [v1.7.1] - 2026-06-02 diff --git a/Package.swift b/Package.swift index 7d43852..5527dd2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,28 @@ // swift-tools-version:5.10 +import Foundation import PackageDescription +let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let idbCheckoutDirectory = ProcessInfo.processInfo.environment["IDB_CHECKOUT_DIR"] + .map { URL(fileURLWithPath: $0) } + ?? packageRoot.appendingPathComponent("idb_checkout", isDirectory: true) +let idbPrivateHeadersDirectory = idbCheckoutDirectory.appendingPathComponent( + "PrivateHeaders", + isDirectory: true +) +// Compile-only module-map inputs: never copy these headers into release artifacts or runtime rpaths. +let idbPrivateHeaderSearchFlags = [ + idbPrivateHeadersDirectory, + idbPrivateHeadersDirectory.appendingPathComponent("AccessibilityPlatformTranslation", isDirectory: true), + idbPrivateHeadersDirectory.appendingPathComponent("AXRuntime", isDirectory: true), + idbPrivateHeadersDirectory.appendingPathComponent("CoreSimDeviceIO", isDirectory: true), + idbPrivateHeadersDirectory.appendingPathComponent("CoreSimulator", isDirectory: true), + idbPrivateHeadersDirectory.appendingPathComponent("CoreSimulatorUtilities", isDirectory: true), + idbPrivateHeadersDirectory.appendingPathComponent("SimulatorKit", isDirectory: true), +// Keep each search path in one token. SwiftPM can otherwise drop the path while propagating +// unsafe flags to its generated test runner, leaving a bare `-I` that consumes the next option. +].map { "-I\($0.path)" } + let package = Package( name: "AXe", platforms: [ @@ -39,7 +61,7 @@ let package = Package( .copy("Resources/skills") ], swiftSettings: [ - .unsafeFlags(["-parse-as-library"]) + .unsafeFlags(["-parse-as-library"] + idbPrivateHeaderSearchFlags) ], linkerSettings: [ // For XCFrameworks, rpath can often be just @executable_path @@ -56,7 +78,11 @@ let package = Package( .testTarget( name: "AXeTests", dependencies: ["AXe", "AXeCore"], - path: "Tests" + path: "Tests", + exclude: ["Goldens"], + swiftSettings: [ + .unsafeFlags(idbPrivateHeaderSearchFlags) + ] ), .plugin( name: "VersionPlugin", diff --git a/README.md b/README.md index c94269c..b213975 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ axe --help axe list-simulators ``` +## Xcode compatibility + +AXe supports Xcode 26 and Xcode 27. Xcode 27 simulator automation uses Device Hub; Simulator.app is not required. + +Release artifacts are built once with the Xcode version selected by the release environment and run unchanged with either supported Xcode selected. Compatibility was validated with Xcode 26.5 (build `17F42`) and iOS 26.5 (`23F77`), and with Xcode 27 Beta 3 (build `27A5218g`) and iOS 27 (`24A5380g`). + +AXe builds IDB from the immutable fork revision `cameroncooke/idb@1395103ca786ee990c70514e1f8bb75fa98cdd82`, based on upstream IDB `e682506725e9efefb9c43b8b917c0b12eb2a5939`. The build does not apply a local patch queue. + +## E2E development + +`make e2e` rebuilds the local IDB XCFrameworks, builds AXe, and runs the simulator tests with the Xcode selected by `DEVELOPER_DIR` or `xcode-select`. XcodeGen is required to generate the IDB projects. When Xcode 27 is selected, the runner starts Device Hub, chooses an available iOS 27 iPhone 17 Pro, and boots it with `simctl`. + +To validate a release payload unchanged under another supported Xcode, first build the test bundle with that Xcode, then run `AXE_BIN_PATH=/path/to/release/axe ./test-runner.sh --tests-only`. The supplied executable must retain its packaged frameworks beside it. + ## Basic usage ```bash diff --git a/Sources/AXe/Commands/Button.swift b/Sources/AXe/Commands/Button.swift index b410d20..6b02cc9 100644 --- a/Sources/AXe/Commands/Button.swift +++ b/Sources/AXe/Commands/Button.swift @@ -91,11 +91,11 @@ struct Button: AsyncParsableCommand { if let duration = duration { // For duration-based presses, we need to create separate down/up events with delay - let buttonDownEvent = FBSimulatorHIDEvent.buttonDown(buttonType.hidButton) + let buttonDownEvent = FBSimulatorHIDEvent.button(direction: .down, button: buttonType.hidButton) let delayEvent = FBSimulatorHIDEvent.delay(duration) - let buttonUpEvent = FBSimulatorHIDEvent.buttonUp(buttonType.hidButton) - - buttonEvent = FBSimulatorHIDEvent(events: [ + let buttonUpEvent = FBSimulatorHIDEvent.button(direction: .up, button: buttonType.hidButton) + + buttonEvent = FBSimulatorHIDEvent.composite([ buttonDownEvent, delayEvent, buttonUpEvent diff --git a/Sources/AXe/Commands/Gesture.swift b/Sources/AXe/Commands/Gesture.swift index 8add73c..e512099 100644 --- a/Sources/AXe/Commands/Gesture.swift +++ b/Sources/AXe/Commands/Gesture.swift @@ -212,7 +212,7 @@ struct Gesture: AsyncParsableCommand { } // Execute the gesture sequence - let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent(events: events) + let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent.composite(events) try await HIDInteractor .performHIDEvent( diff --git a/Sources/AXe/Commands/HIDBrokerCommand.swift b/Sources/AXe/Commands/HIDBrokerCommand.swift new file mode 100644 index 0000000..599c118 --- /dev/null +++ b/Sources/AXe/Commands/HIDBrokerCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Darwin +import FBControlCore + +struct HIDBrokerCommand: AsyncParsableCommand { + // This hidden command is the process entry point used by AXe's HID client. It is an internal + // implementation detail rather than a supported public CLI command. + static let configuration = CommandConfiguration( + commandName: "hid-broker", + shouldDisplay: false + ) + + @Option(name: .customLong("udid")) + var simulatorUDID: String + + func run() async throws { + let endpoint = try HIDBroker.endpointPath(simulatorUDID: simulatorUDID) + let lifetimeLock = try HIDBroker.acquireLifetimeLock(endpoint: endpoint) + defer { + _ = flock(lifetimeLock, LOCK_UN) + Darwin.close(lifetimeLock) + } + let logger = AxeLogger() + try await setup(logger: logger) + try await performGlobalSetup(logger: logger) + try await HIDBroker.serve(simulatorUDID: simulatorUDID, logger: logger) + } +} diff --git a/Sources/AXe/Commands/Key.swift b/Sources/AXe/Commands/Key.swift index 06cbd6e..6df6d72 100644 --- a/Sources/AXe/Commands/Key.swift +++ b/Sources/AXe/Commands/Key.swift @@ -66,11 +66,11 @@ struct Key: AsyncParsableCommand { if let duration = duration { // For duration-based presses, we need to create separate down/up events with delay - let keyDownEvent = FBSimulatorHIDEvent.keyDown(UInt32(keycode)) + let keyDownEvent = FBSimulatorHIDEvent.keyboard(direction: .down, keyCode: UInt32(keycode)) let delayEvent = FBSimulatorHIDEvent.delay(duration) - let keyUpEvent = FBSimulatorHIDEvent.keyUp(UInt32(keycode)) - - keyEvent = FBSimulatorHIDEvent(events: [ + let keyUpEvent = FBSimulatorHIDEvent.keyboard(direction: .up, keyCode: UInt32(keycode)) + + keyEvent = FBSimulatorHIDEvent.composite([ keyDownEvent, delayEvent, keyUpEvent diff --git a/Sources/AXe/Commands/KeyCombo.swift b/Sources/AXe/Commands/KeyCombo.swift index 7add888..8be1c31 100644 --- a/Sources/AXe/Commands/KeyCombo.swift +++ b/Sources/AXe/Commands/KeyCombo.swift @@ -76,7 +76,7 @@ struct KeyCombo: AsyncParsableCommand { // Press modifiers down in order for modifier in parsedModifiers { - events.append(FBSimulatorHIDEvent.keyDown(UInt32(modifier))) + events.append(FBSimulatorHIDEvent.keyboard(direction: .down, keyCode: UInt32(modifier))) } // Press and release the target key @@ -84,10 +84,10 @@ struct KeyCombo: AsyncParsableCommand { // Release modifiers in reverse order for modifier in parsedModifiers.reversed() { - events.append(FBSimulatorHIDEvent.keyUp(UInt32(modifier))) + events.append(FBSimulatorHIDEvent.keyboard(direction: .up, keyCode: UInt32(modifier))) } - let comboEvent = FBSimulatorHIDEvent(events: events) + let comboEvent = FBSimulatorHIDEvent.composite(events) try await HIDInteractor .performHIDEvent( diff --git a/Sources/AXe/Commands/KeySequence.swift b/Sources/AXe/Commands/KeySequence.swift index b732ad9..9a5cb10 100644 --- a/Sources/AXe/Commands/KeySequence.swift +++ b/Sources/AXe/Commands/KeySequence.swift @@ -85,7 +85,7 @@ struct KeySequence: AsyncParsableCommand { } // Create composite event - let sequenceEvent = FBSimulatorHIDEvent(events: events) + let sequenceEvent = FBSimulatorHIDEvent.composite(events) // Perform the key sequence event try await HIDInteractor diff --git a/Sources/AXe/Commands/StreamVideo.swift b/Sources/AXe/Commands/StreamVideo.swift index 6acec44..73f9532 100644 --- a/Sources/AXe/Commands/StreamVideo.swift +++ b/Sources/AXe/Commands/StreamVideo.swift @@ -165,7 +165,7 @@ struct StreamVideo: AsyncParsableCommand { } } - // MARK: - Legacy BGRA streaming + // MARK: - BGRA streaming private func streamBGRA( to simulator: FBSimulator, @@ -177,27 +177,23 @@ struct StreamVideo: AsyncParsableCommand { FileHandle.standardError.write(Data(" axe stream-video --format bgra --udid | ffmpeg -f rawvideo -pixel_format bgra -video_size WIDTHxHEIGHT -i - output.mp4\n".utf8)) FileHandle.standardError.write(Data("Press Ctrl+C to stop streaming\n".utf8)) - do { - let config = FBVideoStreamConfiguration( - encoding: .BGRA, - framesPerSecond: nil, - compressionQuality: NSNumber(value: Double(quality) / 100.0), - scaleFactor: NSNumber(value: scale), - avgBitrate: nil, - keyFrameRate: nil - ) - - let stdoutConsumer = FBFileWriter.syncWriter(withFileDescriptor: STDOUT_FILENO, closeOnEndOfFile: false) - let videoStreamFuture = simulator.createStream(with: config) - let videoStream = try await FutureBridge.value(videoStreamFuture) - let startFuture = videoStream.startStreaming(stdoutConsumer) - - startFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { future in - if let error = future.error { - FileHandle.standardError.write(Data("Stream initialization error: \(error)\n".utf8)) - } - }) + let config = FBVideoStreamConfiguration( + format: .bgra(), + framesPerSecond: NSNumber(value: fps), + rateControl: .quality(NSNumber(value: Double(quality) / 100.0)), + scaleFactor: NSNumber(value: scale), + keyFrameRate: nil + ) + let stdoutConsumer = FBFileWriter.syncWriter(withFileDescriptor: STDOUT_FILENO, closeOnEndOfFile: false) + var videoStream: (any FBVideoStream)? + var isStreaming = false + + do { + let stream = try await simulator.createStream(configuration: config) + videoStream = stream + try await stream.startStreamingAsync(stdoutConsumer) + isStreaming = true try await Task.sleep(nanoseconds: 1_000_000_000) FileHandle.standardError.write(Data("BGRA stream is now running...\n".utf8)) @@ -212,20 +208,14 @@ struct StreamVideo: AsyncParsableCommand { } FileHandle.standardError.write(Data("\nStopping BGRA stream...\n".utf8)) - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - BridgeQueues.videoStreamQueue.async { - let stopFuture = videoStream.stopStreaming() - stopFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { future in - FileHandle.standardError.write(Data("BGRA stream stopped\n".utf8)) - if let error = future.error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - }) - } - } + isStreaming = false + try await stream.stopStreamingAsync() + FileHandle.standardError.write(Data("BGRA stream stopped\n".utf8)) } catch { + if isStreaming, let videoStream { + isStreaming = false + try? await videoStream.stopStreamingAsync() + } throw CLIError(errorDescription: "Failed to stream BGRA video: \(error.localizedDescription)") } } diff --git a/Sources/AXe/Commands/Swipe.swift b/Sources/AXe/Commands/Swipe.swift index 231de39..f7b5c59 100644 --- a/Sources/AXe/Commands/Swipe.swift +++ b/Sources/AXe/Commands/Swipe.swift @@ -137,7 +137,7 @@ struct Swipe: AsyncParsableCommand { } // Execute the swipe sequence - let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent(events: events) + let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent.composite(events) // Perform the swipe event try await HIDInteractor diff --git a/Sources/AXe/Commands/Tap.swift b/Sources/AXe/Commands/Tap.swift index 7e26fb6..06f7184 100644 --- a/Sources/AXe/Commands/Tap.swift +++ b/Sources/AXe/Commands/Tap.swift @@ -166,7 +166,7 @@ struct Tap: AsyncParsableCommand { events.append(.delay(postDelay)) } - let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent(events: events) + let finalEvent = events.count == 1 ? events[0] : FBSimulatorHIDEvent.composite(events) try await HIDInteractor.performHIDEvent(finalEvent, for: simulatorUDID, logger: logger) case .automatic: throw CLIError(errorDescription: "Unexpected tap style resolution.") diff --git a/Sources/AXe/Commands/Touch.swift b/Sources/AXe/Commands/Touch.swift index 48c360d..1ffc421 100644 --- a/Sources/AXe/Commands/Touch.swift +++ b/Sources/AXe/Commands/Touch.swift @@ -78,49 +78,31 @@ struct Touch: AsyncParsableCommand { logger: logger ) + var primitives: [HIDBrokerPrimitive] = [] if touchDown && touchUp { // Send down and up as separate HID submissions so iOS recognizers // observe a real hold duration for long-press gestures. let touchDelay = delay ?? TapTiming.defaultHoldDuration logger.info().log("Touch down") - try await HIDInteractor - .performHIDEvent( - FBSimulatorHIDEvent.touchDownAt(x: physicalPoint.x, y: physicalPoint.y), - for: simulatorUDID, - logger: logger - ) + primitives.append(.touch(.down, x: physicalPoint.x, y: physicalPoint.y)) if touchDelay > 0 { logger.info().log("Delay: \(touchDelay) seconds") - let delayNanoseconds = UInt64(touchDelay * 1_000_000_000) - try await Task.sleep(nanoseconds: delayNanoseconds) + primitives.append(.delay(touchDelay)) } logger.info().log("Touch up") - try await HIDInteractor - .performHIDEvent( - FBSimulatorHIDEvent.touchUpAt(x: physicalPoint.x, y: physicalPoint.y), - for: simulatorUDID, - logger: logger - ) + primitives.append(.touch(.up, x: physicalPoint.x, y: physicalPoint.y)) } else if touchDown { logger.info().log("Touch down") - try await HIDInteractor - .performHIDEvent( - FBSimulatorHIDEvent.touchDownAt(x: physicalPoint.x, y: physicalPoint.y), - for: simulatorUDID, - logger: logger - ) + primitives.append(.touch(.down, x: physicalPoint.x, y: physicalPoint.y)) } else { logger.info().log("Touch up") - try await HIDInteractor - .performHIDEvent( - FBSimulatorHIDEvent.touchUpAt(x: physicalPoint.x, y: physicalPoint.y), - for: simulatorUDID, - logger: logger - ) + primitives.append(.touch(.up, x: physicalPoint.x, y: physicalPoint.y)) } + + try HIDBroker.sendTouchPrimitives(primitives, simulatorUDID: simulatorUDID) logger.info().log("Touch events completed successfully") } diff --git a/Sources/AXe/Commands/Type.swift b/Sources/AXe/Commands/Type.swift index 7abc7d1..2a12e87 100644 --- a/Sources/AXe/Commands/Type.swift +++ b/Sources/AXe/Commands/Type.swift @@ -129,14 +129,15 @@ struct Type: AsyncParsableCommand { logger.info().log("Performing HID event sequence for text typing") - // Perform HID events sequentially - for event in hidEvents { - try await HIDInteractor - .performHIDEvent( - event, - for: simulatorUDID, - logger: logger - ) + if !hidEvents.isEmpty { + // Keep typing in one ordered session. Indigo awaits each send, while DTUHID adds its + // own keyboard pacing; unconditional delays here would double-pace the DTUHID path. + try await HIDInteractor + .performHIDEvent( + .composite(hidEvents), + for: simulatorUDID, + logger: logger + ) } logger.info().log("Text typing completed successfully") diff --git a/Sources/AXe/Commands/VideoCommandSupport.swift b/Sources/AXe/Commands/VideoCommandSupport.swift index d042f85..28d4972 100644 --- a/Sources/AXe/Commands/VideoCommandSupport.swift +++ b/Sources/AXe/Commands/VideoCommandSupport.swift @@ -47,20 +47,27 @@ final class SignalObserver { } } -enum VideoProcessingError: Error { - case emptyScreenshot +enum VideoProcessingError: LocalizedError, UserFacingError { case failedToDecodeImage case failedToAllocatePixelBuffer + + var userFacingDescription: String { + switch self { + case .failedToDecodeImage: + return "AXe could not decode a simulator video frame." + case .failedToAllocatePixelBuffer: + return "AXe could not allocate memory for a simulator video frame." + } + } + + var errorDescription: String? { + userFacingDescription + } } struct VideoFrameUtilities { static func captureScreenshotData(from simulator: FBSimulator) async throws -> Data { - let screenshotFuture = simulator.takeScreenshot(.PNG) - let nsData = try await FutureBridge.value(screenshotFuture) - guard let data = nsData as Data? else { - throw VideoProcessingError.emptyScreenshot - } - return data + try await simulator.takeScreenshot(format: .png) } static func makeCGImage(from data: Data) -> CGImage? { @@ -89,27 +96,12 @@ struct VideoFrameUtilities { private static func scaleJPEGData(_ data: Data, scale: Double, quality: Int) async throws -> Data { #if os(macOS) - guard let image = NSImage(data: data) else { - throw VideoProcessingError.failedToDecodeImage - } - - let newSize = NSSize( - width: image.size.width * scale, - height: image.size.height * scale - ) - - let newImage = NSImage(size: newSize) - newImage.lockFocus() - image.draw(in: NSRect(origin: .zero, size: newSize)) - newImage.unlockFocus() - - guard let tiffData = newImage.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { + guard let image = makeCGImage(from: data) else { throw VideoProcessingError.failedToDecodeImage } - - return jpegData + let width = max(1, Int(Double(image.width) * scale)) + let height = max(1, Int(Double(image.height) * scale)) + return try encodeJPEG(image, width: width, height: height, quality: quality) #else return data #endif @@ -117,18 +109,53 @@ struct VideoFrameUtilities { private static func reencodeJPEGData(_ data: Data, quality: Int) async throws -> Data { #if os(macOS) - guard let image = NSImage(data: data), - let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { + guard let image = makeCGImage(from: data) else { throw VideoProcessingError.failedToDecodeImage } - - return jpegData + return try encodeJPEG(image, width: image.width, height: image.height, quality: quality) #else return data #endif } + + #if os(macOS) + private static func encodeJPEG( + _ image: CGImage, + width: Int, + height: Int, + quality: Int + ) throws -> Data { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), let context = NSGraphicsContext(bitmapImageRep: bitmap) else { + throw VideoProcessingError.failedToAllocatePixelBuffer + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.imageInterpolation = .high + context.cgContext.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation( + using: .jpeg, + properties: [.compressionFactor: Double(quality) / 100.0] + ) else { + throw VideoProcessingError.failedToDecodeImage + } + return data + } + #endif } final class H264StreamRecorder: @unchecked Sendable { diff --git a/Sources/AXe/Extensions/AsyncParsableCommand+Setup.swift b/Sources/AXe/Extensions/AsyncParsableCommand+Setup.swift index 2ba4d0c..4856d8f 100644 --- a/Sources/AXe/Extensions/AsyncParsableCommand+Setup.swift +++ b/Sources/AXe/Extensions/AsyncParsableCommand+Setup.swift @@ -7,22 +7,30 @@ extension AsyncParsableCommand { func setup(logger: AxeLogger) async throws { // Check Xcode availability do { - let isXcodeAvailable: NSString = try await FutureBridge.value(FBXcodeDirectory.xcodeSelectDeveloperDirectory()) - if isXcodeAvailable.length == 0 { - logger.error().log("Xcode is not available, idb will not be able to use Simulators") - throw CLIError(errorDescription: "Xcode is not available, idb will not be able to use Simulators") + let developerDirectory = try FBXcodeDirectory.resolveDeveloperDirectory() + if developerDirectory.isEmpty { + logger.error().log("No active Xcode developer directory was found") + throw CLIError( + errorDescription: "AXe could not find an active Xcode installation. Select Xcode with `xcode-select` or set `DEVELOPER_DIR`, then try again." + ) } + } catch let error as CLIError { + throw error } catch { - logger.error().log("Xcode is not available, idb will not be able to use Simulators: \(error.localizedDescription)") - throw CLIError(errorDescription: "Xcode is not available, idb will not be able to use Simulators") + logger.error().log("Failed to resolve the active Xcode installation: \(error.localizedDescription)") + throw CLIError( + errorDescription: "AXe could not find an active Xcode installation. Select Xcode with `xcode-select` or set `DEVELOPER_DIR`, then try again." + ) } // Load essential frameworks do { try FBSimulatorControlFrameworkLoader.essentialFrameworks.loadPrivateFrameworks(logger) } catch { - logger.info().log("Essential private frameworks failed to loaded.") - throw CLIError(errorDescription: "Essential private frameworks failed to loaded.") + logger.error().log("Failed to load simulator support: \(error.localizedDescription)") + throw CLIError( + errorDescription: "AXe could not load simulator support from the selected Xcode installation. Confirm Xcode 26 or later is selected and try again." + ) } } -} \ No newline at end of file +} diff --git a/Sources/AXe/Types/Errors.swift b/Sources/AXe/Types/Errors.swift index d27701f..3657da4 100644 --- a/Sources/AXe/Types/Errors.swift +++ b/Sources/AXe/Types/Errors.swift @@ -1,6 +1,30 @@ import Foundation // MARK: - Error Types -struct CLIError: LocalizedError { +protocol UserFacingError: Error, CustomStringConvertible { + var userFacingDescription: String { get } +} + +extension UserFacingError { + var description: String { + userFacingDescription + } +} + +struct CLIError: LocalizedError, UserFacingError { let errorDescription: String -} \ No newline at end of file + + init(errorDescription: String) { + self.errorDescription = errorDescription + } + + static func simulatorNotFound(udid: String) -> CLIError { + CLIError( + errorDescription: "No simulator with UDID \(udid) was found. Run `axe list-simulators` to see available simulators." + ) + } + + var userFacingDescription: String { + errorDescription + } +} diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index e81da5c..3f21140 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -2,6 +2,23 @@ import Foundation import FBControlCore import FBSimulatorControl +struct AccessibilityRecoveryDependencies { + typealias ProcessRunner = @MainActor ( + _ executableURL: URL, + _ arguments: [String], + _ timeout: TimeInterval + ) async throws -> Int32 + typealias Waiter = @MainActor (_ duration: Duration) async throws -> Void + + let runProcess: ProcessRunner + let wait: Waiter + + static let live = AccessibilityRecoveryDependencies( + runProcess: AccessibilityFetcher.runProcess, + wait: { duration in try await Task.sleep(for: duration) } + ) +} + struct AccessibilityPoint: Equatable { let x: Double let y: Double @@ -17,24 +34,319 @@ struct AccessibilityFetcher { static func fetchAccessibilityInfoJSONData( for simulatorUDID: String, point: AccessibilityPoint? = nil, - logger: AxeLogger + logger: AxeLogger, + recoveryDependencies: AccessibilityRecoveryDependencies = .live ) async throws -> Data { let simulatorSet = try await getSimulatorSet(deviceSetPath: nil, logger: logger, reporter: EmptyEventReporter.shared) guard let target = simulatorSet.allSimulators.first(where: { $0.udid == simulatorUDID }) else { - throw CLIError(errorDescription: "Simulator with UDID \(simulatorUDID) not found in set.") + throw CLIError.simulatorNotFound(udid: simulatorUDID) + } + + return try await retryingAfterTestManagerRecovery( + simulatorUDID: simulatorUDID, + logger: logger, + dependencies: recoveryDependencies + ) { + if let point { + return try await fetchAccessibilityInfoJSONData(from: target, at: point) + } + return try await fetchFrontmostAccessibilityInfoJSONData(from: target) + } + } + + private static func fetchAccessibilityInfoJSONData( + from target: FBSimulator, + at point: AccessibilityPoint + ) async throws -> Data { + try await retryingTransientPointFallback(at: point) { + let accessibilityElement = try await target.accessibilityElement(at: point.cgPoint) + defer { accessibilityElement.close() } + return try serializedAccessibilityData(from: accessibilityElement) + } + } + + static func retryingTransientPointFallback( + at point: AccessibilityPoint, + maximumAttempts: Int = 5, + fetch: @MainActor () async throws -> Data, + wait: @MainActor (Duration) async throws -> Void = { duration in + try await Task.sleep(for: duration) + } + ) async throws -> Data { + precondition(maximumAttempts > 0) + var latestData: Data? + for attempt in 0.. Data { + var latestData: Data? + for attempt in 0..<5 { + // IDB's former `accessibilityElements(withNestedFormat:)` API also serialized the + // frontmost application internally. This explicit handle API preserves that scope. + let accessibilityElement = try await target.accessibilityElementForFrontmostApplication() + let data: Data + do { + data = try serializedAccessibilityData(from: accessibilityElement) + accessibilityElement.close() + } catch { + accessibilityElement.close() + throw error + } + latestData = data + if try containsAccessibilityDescendant(in: data) { + return data + } + if attempt < 4 { + try await Task.sleep(for: .milliseconds(50 * (1 << attempt))) + } + } + guard let latestData else { + throw CLIError(errorDescription: "Accessibility hierarchy could not be serialized.") + } + return latestData + } + + static func retryingAfterTestManagerRecovery( + simulatorUDID: String, + logger: AxeLogger, + dependencies: AccessibilityRecoveryDependencies, + operation: @MainActor () async throws -> T + ) async throws -> T { + do { + return try await operation() + } catch { + guard shouldRecoverTestManagerDaemon(from: error) else { + throw error + } + logger.info().log("Accessibility transport failed; restarting testmanagerd and retrying once") + try await recoverTestManagerDaemon( + simulatorUDID: simulatorUDID, + dependencies: dependencies + ) + return try await operation() + } + } + + static func shouldRecoverTestManagerDaemon(from error: Error) -> Bool { + let errors = errorChain(from: error) + let details = errors.flatMap { error in + [ + error.domain, + error.localizedDescription, + error.localizedFailureReason, + error.localizedRecoverySuggestion, + error.userInfo[NSDebugDescriptionErrorKey] as? String, + ].compactMap { $0?.lowercased() } + } + if details.contains(where: { normalizedErrorText($0) == "channel disconnected" }) { + return true + } + let identifiesDTX = details.contains(where: { $0.contains("dtx") }) + let identifiesFileDescriptorExhaustion = errors.contains { error in + error.code == 24 + || error.localizedDescription.localizedCaseInsensitiveContains("errno 24") + || error.localizedDescription.localizedCaseInsensitiveContains("too many open files") + } + return identifiesDTX && identifiesFileDescriptorExhaustion + } + + private static func normalizedErrorText(_ text: String) -> String { + text.lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + static func recoverTestManagerDaemon( + simulatorUDID: String, + dependencies: AccessibilityRecoveryDependencies + ) async throws { + let arguments = [ + "simctl", + "spawn", + simulatorUDID, + "launchctl", + "kickstart", + "-k", + "user/foreground/com.apple.testmanagerd", + ] + let status = try await dependencies.runProcess( + URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments, + 3 + ) + guard status == 0 else { + throw CLIError( + errorDescription: "AXe could not restore accessibility automation for simulator \(simulatorUDID). Restart the simulator and try again." + ) + } + try await dependencies.wait(.milliseconds(250)) + } + + private static func errorChain(from error: Error) -> [NSError] { + var errors: [NSError] = [] + var current: NSError? = error as NSError + var visited: Set = [] + while let next = current, visited.insert(ObjectIdentifier(next)).inserted { + errors.append(next) + current = next.userInfo[NSUnderlyingErrorKey] as? NSError + } + return errors + } + + static func runProcess( + executableURL: URL, + arguments: [String], + timeout: TimeInterval + ) async throws -> Int32 { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + do { + try await Task.sleep(for: .milliseconds(10)) + } catch { + terminateProcess(process) + throw error + } + } + if process.isRunning { + terminateProcess(process) + throw CLIError(errorDescription: "AXe timed out while restoring accessibility automation.") + } + process.waitUntilExit() + return process.terminationStatus + } + + private static func terminateProcess(_ process: Process) { + if process.isRunning { + process.terminate() + let deadline = Date().addingTimeInterval(0.5) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } } + process.waitUntilExit() + } + + private static func serializedAccessibilityData(from accessibilityElement: FBAccessibilityElement) throws -> Data { + let response = try accessibilityElement.serialize( + with: FBAccessibilityRequestOptions( + nestedFormat: true, + keys: accessibilityRequestKeys + ) + ) + return try serializeAccessibilityInfo(addingCompatibilityDefaults(to: response.elements)) + } - // FBSimulator conforms to FBAccessibilityCommands. - let accessibilityInfoFuture: FBFuture - if let point { - accessibilityInfoFuture = target.accessibilityElement(at: point.cgPoint, nestedFormat: true) + static func containsAccessibilityDescendant(in data: Data) throws -> Bool { + let object = try JSONSerialization.jsonObject(with: data) + let roots: [[String: Any]] + if let array = object as? [[String: Any]] { + roots = array + } else if let root = object as? [String: Any] { + roots = [root] } else { - accessibilityInfoFuture = target.accessibilityElements(withNestedFormat: true) + return false + } + return roots.contains { root in + guard let children = root["children"] as? [Any] else { return false } + return !children.isEmpty + } + } + + static func isTransientPointFallback(in data: Data, at point: AccessibilityPoint) throws -> Bool { + let object = try JSONSerialization.jsonObject(with: data) + let root: [String: Any]? + if let dictionary = object as? [String: Any] { + root = dictionary + } else if let dictionaries = object as? [[String: Any]], dictionaries.count == 1 { + root = dictionaries.first + } else { + root = nil + } + let meaningfulKeys = [ + "AXLabel", + "AXUniqueId", + "AXIdentifier", + "AXValue", + "title", + "help", + "custom_actions", + "children", + ] + guard let root, + !meaningfulKeys.contains(where: { hasMeaningfulValue(root[$0]) }) else { + return false } - let infoAnyObject: AnyObject = try await FutureBridge.value(accessibilityInfoFuture) - return try serializeAccessibilityInfo(infoAnyObject) + let decoder = JSONDecoder() + let element: AccessibilityElement + if let decoded = try? decoder.decode(AccessibilityElement.self, from: data) { + element = decoded + } else if let decoded = try? decoder.decode([AccessibilityElement].self, from: data), + let first = decoded.first, + decoded.count == 1 { + element = first + } else { + return false + } + + guard element.type == "Group", + element.role == "AXGroup", + element.normalizedLabel == nil, + element.normalizedUniqueId == nil, + element.normalizedValue == nil, + element.children?.isEmpty != false, + let frame = element.frame, + abs(frame.x) < 1, + abs(frame.y) < 1 else { + return false + } + let containsPoint = point.x >= frame.x + && point.x <= frame.x + frame.width + && point.y >= frame.y + && point.y <= frame.y + frame.height + return containsPoint + && min(frame.width, frame.height) >= 300 + && max(frame.width, frame.height) >= 600 + } + + private static func hasMeaningfulValue(_ value: Any?) -> Bool { + switch value { + case nil, is NSNull: + return false + case let string as String: + return !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case let array as [Any]: + return !array.isEmpty + case let dictionary as [String: Any]: + return !dictionary.isEmpty + default: + return true + } } static func fetchAccessibilityElements(for simulatorUDID: String, logger: AxeLogger) async throws -> [AccessibilityElement] { @@ -49,14 +361,51 @@ struct AccessibilityFetcher { return [root] } - private static func serializeAccessibilityInfo(_ accessibilityInfo: AnyObject) throws -> Data { - if let nsDict = accessibilityInfo as? NSDictionary { - return try JSONSerialization.data(withJSONObject: nsDict, options: [.prettyPrinted]) + static func serializeAccessibilityInfo(_ accessibilityInfo: Any) throws -> Data { + guard accessibilityInfo is [String: Any] || accessibilityInfo is [[String: Any]] else { + throw CLIError(errorDescription: "AXe received an unsupported accessibility response from the simulator.") } - if let nsArray = accessibilityInfo as? NSArray { - return try JSONSerialization.data(withJSONObject: nsArray, options: [.prettyPrinted]) + return try JSONSerialization.data(withJSONObject: accessibilityInfo, options: [.prettyPrinted]) + } + + static func addingCompatibilityDefaults(to accessibilityInfo: Any) -> Any { + if let elements = accessibilityInfo as? [Any] { + return elements.map(addingCompatibilityDefaults) } - - throw CLIError(errorDescription: "Accessibility info was not a dictionary or array as expected.") + guard var element = accessibilityInfo as? [String: Any] else { + return accessibilityInfo + } + for (key, value) in element { + element[key] = addingCompatibilityDefaults(to: value) + } + if element["type"] != nil, element["traits"] == nil { + element["traits"] = [String]() + } + return element } -} + + // This key set preserves AXe's legacy public JSON schema; update it only with explicit schema coverage. + static let accessibilityOutputKeys: Set = [ + .label, + .frame, + .value, + .uniqueID, + .type, + .title, + .frameDict, + .help, + .enabled, + .customActions, + .role, + .roleDescription, + .subrole, + .contentRequired, + .pid, + .traits, + ] + + // AXe's former IDB serializer did not return traits. Xcode 27's private nested serializer also + // returns an empty hierarchy when `.traits` is requested, so retain the existing public value + // as an empty compatibility placeholder after requesting the safe subset on every Xcode version. + static let accessibilityRequestKeys = accessibilityOutputKeys.subtracting([.traits]) +} diff --git a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift index 956caed..9e71435 100644 --- a/Sources/AXe/Utilities/AccessibilityTargetResolver.swift +++ b/Sources/AXe/Utilities/AccessibilityTargetResolver.swift @@ -15,7 +15,7 @@ enum AccessibilityQuery { } } -enum ElementResolutionError: LocalizedError { +enum ElementResolutionError: LocalizedError, UserFacingError { case notFound(kind: String, value: String) case multipleMatches(count: Int, kind: String, value: String, hasUniqueIDs: Bool) case invalidFrame(reason: String) @@ -42,6 +42,10 @@ enum ElementResolutionError: LocalizedError { if case .notFound = self { return true } return false } + + var userFacingDescription: String { + errorDescription ?? "AXe could not resolve the requested accessibility element." + } } struct AccessibilityMatch { diff --git a/Sources/AXe/Utilities/Batch/BatchPlanRunner.swift b/Sources/AXe/Utilities/Batch/BatchPlanRunner.swift index b912228..ca4637c 100644 --- a/Sources/AXe/Utilities/Batch/BatchPlanRunner.swift +++ b/Sources/AXe/Utilities/Batch/BatchPlanRunner.swift @@ -11,7 +11,7 @@ struct BatchPlanRunner { func flushPending() async throws { guard !pendingMergeable.isEmpty else { return } - let event = pendingMergeable.count == 1 ? pendingMergeable[0] : FBSimulatorHIDEvent(events: pendingMergeable) + let event = pendingMergeable.count == 1 ? pendingMergeable[0] : FBSimulatorHIDEvent.composite(pendingMergeable) try await HIDInteractor.performHIDEvent(event, in: session, logger: logger) pendingMergeable.removeAll(keepingCapacity: true) } @@ -22,6 +22,8 @@ struct BatchPlanRunner { pendingMergeable.append(event) case .hidBarrier(let event): try await flushPending() + // A barrier prevents event coalescing; failures propagate without replaying + // this event or any earlier event in the batch. try await HIDInteractor.performHIDEvent(event, in: session, logger: logger) case .hostSleep(let seconds): try await flushPending() @@ -43,4 +45,3 @@ struct BatchPlanRunner { try await flushPending() } } - diff --git a/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift b/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift index 5918f5e..95acc51 100644 --- a/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift +++ b/Sources/AXe/Utilities/Batch/Command+BatchConvertible.swift @@ -20,7 +20,7 @@ private func buildDelayedEvent( if let postDelay, postDelay > 0 { events.append(.delay(postDelay)) } - return events.count == 1 ? events[0] : FBSimulatorHIDEvent(events: events) + return events.count == 1 ? events[0] : FBSimulatorHIDEvent.composite(events) } private func resolveBatchTapPoint( @@ -186,8 +186,8 @@ extension Touch: BatchConvertible { logger: logger ) - let touchDownEvent = FBSimulatorHIDEvent.touchDownAt(x: physicalPoint.x, y: physicalPoint.y) - let touchUpEvent = FBSimulatorHIDEvent.touchUpAt(x: physicalPoint.x, y: physicalPoint.y) + let touchDownEvent = FBSimulatorHIDEvent.touch(direction: .down, x: physicalPoint.x, y: physicalPoint.y) + let touchUpEvent = FBSimulatorHIDEvent.touch(direction: .up, x: physicalPoint.x, y: physicalPoint.y) if touchDown && touchUp { let holdDelay = delay ?? TapTiming.defaultHoldDuration @@ -209,10 +209,10 @@ extension Touch: BatchConvertible { extension Button: BatchConvertible { func toBatchPrimitives(context: BatchContext, logger: AxeLogger) async throws -> [BatchPrimitive] { if let duration { - let composite = FBSimulatorHIDEvent(events: [ - .buttonDown(buttonType.hidButton), + let composite = FBSimulatorHIDEvent.composite([ + .button(direction: .down, button: buttonType.hidButton), .delay(duration), - .buttonUp(buttonType.hidButton) + .button(direction: .up, button: buttonType.hidButton) ]) return [.hidMergeable(composite)] } @@ -224,10 +224,10 @@ extension Button: BatchConvertible { extension Key: BatchConvertible { func toBatchPrimitives(context: BatchContext, logger: AxeLogger) async throws -> [BatchPrimitive] { if let duration { - let composite = FBSimulatorHIDEvent(events: [ - .keyDown(UInt32(keycode)), + let composite = FBSimulatorHIDEvent.composite([ + .keyboard(direction: .down, keyCode: UInt32(keycode)), .delay(duration), - .keyUp(UInt32(keycode)) + .keyboard(direction: .up, keyCode: UInt32(keycode)) ]) return [.hidMergeable(composite)] } @@ -249,7 +249,7 @@ extension KeySequence: BatchConvertible { } } - return [.hidMergeable(FBSimulatorHIDEvent(events: events))] + return [.hidMergeable(FBSimulatorHIDEvent.composite(events))] } } @@ -259,14 +259,14 @@ extension KeyCombo: BatchConvertible { var events: [FBSimulatorHIDEvent] = [] for modifier in parsedModifiers { - events.append(.keyDown(UInt32(modifier))) + events.append(.keyboard(direction: .down, keyCode: UInt32(modifier))) } events.append(.shortKeyPress(UInt32(key))) for modifier in parsedModifiers.reversed() { - events.append(.keyUp(UInt32(modifier))) + events.append(.keyboard(direction: .up, keyCode: UInt32(modifier))) } - return [.hidMergeable(FBSimulatorHIDEvent(events: events))] + return [.hidMergeable(FBSimulatorHIDEvent.composite(events))] } } @@ -299,7 +299,7 @@ extension Type: BatchConvertible { switch context.typeSubmissionMode { case .composite: - return [.hidMergeable(FBSimulatorHIDEvent(events: hidEvents))] + return [.hidMergeable(FBSimulatorHIDEvent.composite(hidEvents))] case .chunked: let chunkSize = max(1, context.typeChunkSize) var primitives: [BatchPrimitive] = [] @@ -307,11 +307,10 @@ extension Type: BatchConvertible { while start < hidEvents.count { let end = min(start + chunkSize, hidEvents.count) let chunkEvents = Array(hidEvents[start..(_ futures: FBFuture...) async throws -> [T] { - let futuresArr: [FBFuture] = futures - return try await values(futuresArr) - } - - /// Use this to receive results from multiple futures. The results are **ordered in the same order as passed futures**, so you can safely access them from - /// array by indexes. - /// - Note: We should *not* use @discardableResult, results should be dropped explicitly by the callee. - public static func values(_ futures: [FBFuture]) async throws -> [T] { - try await withThrowingTaskGroup(of: T?.self) { group in - for future in futures { - group.addTask { - try await value(future) - } - } - var results = [T]() - for try await result in group { - guard let result = result else { - throw FBFutureError.taskGroupReceivedNilResultInternalError - } - results.append(result) - } - return results - } - } - - /// Awaitable value that waits for publishing from the wrapped future - /// - Note: We should *not* use @discardableResult, results should be dropped explicitly by the callee. - public static func value(_ future: FBFuture) async throws -> T { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - future.onQueue(BridgeQueues.futureSerialFullfillmentQueue, notifyOfCompletion: { resolvedFuture in - if let error = resolvedFuture.error { - continuation.resume(throwing: error) - } else if let value = resolvedFuture.result { - continuation.resume(returning: value as! T) - } else { - continuation.resume(throwing: FBFutureError.continuationFulfilledWithoutValues) - } - }) - } - } onCancel: { - future.cancel() - } - } - - /// Awaitable value that waits for publishing from the wrapped future. - /// This is convenient bridgeable overload for dealing with objc `NSArray`. - /// - Warning: This operation not safe (as most of objc bridge). That means you should be sure that type bridging will succeed. - public static func value(_ future: FBFuture) async throws -> [T] { - let objcValue = try await value(future as FBFuture) - // swiftlint:disable force_cast - return objcValue as! [T] - // swiftlint:enable force_cast - } - - /// Awaitable value that waits for publishing from the wrapped future. - /// This is convenient bridgeable overload for dealing with objc `NSDictionary`. - /// - Warning: This operation not safe (as most of objc bridge). That means you should be sure that type bridging will succeed. - public static func value(_ future: FBFuture) async throws -> [T: U] { - let objcValue = try await value(future) - // swiftlint:disable force_cast - return objcValue as! [T: U] - } - - /// Special overload for FBFuture representing void. - public static func value(_ future: FBFuture) async throws { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - future.onQueue(BridgeQueues.futureSerialFullfillmentQueue, notifyOfCompletion: { resolvedFuture in - if let error = resolvedFuture.error { - continuation.resume(throwing: error) - } else { - // For NSNull futures, we just complete with void - continuation.resume(returning: ()) - } - }) - } - } onCancel: { - future.cancel() - } - } - - /// Special overload for FBFuture representing Bool. - public static func value(_ future: FBFuture) async throws -> Bool { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - future.onQueue(BridgeQueues.futureSerialFullfillmentQueue, notifyOfCompletion: { resolvedFuture in - if let error = resolvedFuture.error { - continuation.resume(throwing: error) - } else if let value = resolvedFuture.result { - continuation.resume(returning: value.boolValue) - } else { - continuation.resume(throwing: FBFutureError.continuationFulfilledWithoutValues) - } - }) - } - } onCancel: { - future.cancel() - } - } - - /// Awaitable value that waits for publishing from the wrapped futureContext. - /// This is convenient bridgeable overload for dealing with objc `NSArray`. - public static func value(_ futureContext: FBFutureContext) async throws -> T { - return try await value(futureContext.future) - } -} - -// Typealias HACKS to satisfy AnyObject conformance for FBFuture generic constraints -// FBFuture requires T to be AnyObject. -// NSArray, NSNull, NSNumber are AnyObjects. -private typealias NSArray_TypeHack = NSArray -private typealias NSNull_TypeHack = NSNull -private typealias NSNumber_TypeHack = NSNumber \ No newline at end of file diff --git a/Sources/AXe/Utilities/GlobalSetup.swift b/Sources/AXe/Utilities/GlobalSetup.swift index 985f0a3..248a4fd 100644 --- a/Sources/AXe/Utilities/GlobalSetup.swift +++ b/Sources/AXe/Utilities/GlobalSetup.swift @@ -1,7 +1,6 @@ import Foundation import FBControlCore import FBSimulatorControl -import ObjectiveC // For objc_lookUpClass @MainActor func performGlobalSetup(logger: AxeLogger) async throws { @@ -10,16 +9,17 @@ func performGlobalSetup(logger: AxeLogger) async throws { // Check Xcode availability logger.info().log("Checking Xcode availability...") do { - let xcodePathFuture = FBXcodeDirectory.xcodeSelectDeveloperDirectory() - let xcodePath = try await FutureBridge.value(xcodePathFuture) - if xcodePath.length == 0 { - let errorMessage = "Xcode is not available (xcode-select path is empty). FBSimulatorControl may not function correctly." + let xcodePath = try FBXcodeDirectory.resolveDeveloperDirectory() + if xcodePath.isEmpty { + let errorMessage = "AXe could not find an active Xcode installation. Select Xcode with `xcode-select` or set `DEVELOPER_DIR`, then try again." logger.error().log(errorMessage) throw CLIError(errorDescription: errorMessage) } logger.info().log("Xcode is available at: \(xcodePath)") + } catch let error as CLIError { + throw error } catch { - let errorMessage = "Failed to check Xcode availability: \(error.localizedDescription)" + let errorMessage = "AXe could not resolve the active Xcode installation: \(error.localizedDescription)" logger.error().log(errorMessage) throw CLIError(errorDescription: errorMessage) } @@ -34,20 +34,8 @@ func performGlobalSetup(logger: AxeLogger) async throws { logger.info().log("Loading Xcode frameworks (including SimulatorKit)...") try FBSimulatorControlFrameworkLoader.xcodeFrameworks.loadPrivateFrameworks(logger) logger.info().log("Successfully loaded Xcode frameworks.") - - // Explicitly check if the critical class is now available - let clientClassName = "SimulatorKit.SimDeviceLegacyHIDClient" - let clientClass: AnyClass? = objc_lookUpClass(clientClassName) - if clientClass == nil { - let criticalMessage = "CRITICAL FAILURE: Class '\(clientClassName)' NOT FOUND by objc_lookUpClass even after FBSimulatorControlFrameworkLoader call." - logger.error().log(criticalMessage) - throw CLIError(errorDescription: criticalMessage) // This should halt execution if class isn't found - } else { - logger.info().log("CONFIRMED: Class '\(clientClassName)' was successfully found by objc_lookUpClass.") - } - } catch { - let errorMessage = "Failed to load essential private frameworks: \(error.localizedDescription)" + let errorMessage = "AXe could not load simulator support from the selected Xcode installation: \(error.localizedDescription)" logger.error().log(errorMessage) throw CLIError(errorDescription: errorMessage) } diff --git a/Sources/AXe/Utilities/HIDBroker+Connection.swift b/Sources/AXe/Utilities/HIDBroker+Connection.swift new file mode 100644 index 0000000..809dc9f --- /dev/null +++ b/Sources/AXe/Utilities/HIDBroker+Connection.swift @@ -0,0 +1,426 @@ +import Darwin +import Dispatch +import Foundation +import OSLog + +// Every connection receives this newline-delimited handshake before the client writes a request. +// A false value guarantees that the broker has not read or executed any touch primitives. +private struct HIDBrokerHandshake: Codable { + let ready: Bool +} + +private struct HIDBrokerResponse: Codable { + let error: String? +} + +struct HIDBrokerNotReadyError: LocalizedError, UserFacingError { + let userFacingDescription = "AXe could not establish simulator input. Wait for the simulator to finish booting and try again." + let diagnosticDescription: String + let isSafeToReplaceBroker: Bool + let allowsReplacementAfterProcessExit: Bool + + init( + diagnosticDescription: String = "The broker reported that its simulator session is stale.", + isSafeToReplaceBroker: Bool = true, + allowsReplacementAfterProcessExit: Bool = false + ) { + self.diagnosticDescription = diagnosticDescription + self.isSafeToReplaceBroker = isSafeToReplaceBroker + self.allowsReplacementAfterProcessExit = allowsReplacementAfterProcessExit + } + + var errorDescription: String? { + userFacingDescription + } +} + +struct HIDBrokerSocketIdentity: Equatable { + let device: dev_t + let inode: ino_t +} + +extension HIDBroker { + private static let diagnosticLogger = Logger( + subsystem: "com.cameroncooke.axe", + category: "HIDBroker" + ) + + static func makeListener(at path: String) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw posixError("socket") } + configureNoSignalPipe(descriptor) + do { + var address = try makeAddress(path: path) + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw posixError("bind") } + guard chmod(path, S_IRUSR | S_IWUSR) == 0 else { throw posixError("chmod") } + // A full Unix-domain socket backlog can report ECONNREFUSED to clients. Use the + // platform maximum to absorb command bursts; the lifetime lock below remains the + // authoritative signal when the backlog is nevertheless exhausted. + guard listen(descriptor, SOMAXCONN) == 0 else { throw posixError("listen") } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + static func connectAndAwaitHandshake( + endpoint: String, + connector: (String) throws -> Int32, + timeoutMilliseconds: Int = serverIOTimeoutMilliseconds + ) throws -> Int32 { + let descriptor = try connector(endpoint) + do { + try configureReceiveTimeout(descriptor, milliseconds: timeoutMilliseconds) + let data = try readMessage(from: descriptor) + let handshake = try JSONDecoder().decode(HIDBrokerHandshake.self, from: data) + guard handshake.ready else { throw HIDBrokerNotReadyError() } + try configureReceiveTimeout(descriptor, milliseconds: clientResponseTimeoutMilliseconds) + return descriptor + } catch { + Darwin.close(descriptor) + if let notReady = error as? HIDBrokerNotReadyError { + throw notReady + } + throw HIDBrokerNotReadyError( + diagnosticDescription: "The broker readiness handshake failed: \(error.localizedDescription)", + isSafeToReplaceBroker: false, + allowsReplacementAfterProcessExit: true + ) + } + } + + static func acquireStartupLock( + endpoint: String, + deadline: UInt64, + monotonicNow: () -> UInt64, + sleeper: (useconds_t) -> Void + ) throws -> Int32 { + let path = endpoint + ".lock" + while monotonicNow() < deadline { + do { + return try acquirePrivateLock(path: path, operation: "startup lock", nonblocking: true) + } catch let error as NSError + where error.domain == NSPOSIXErrorDomain && error.code == Int(EWOULDBLOCK) { + sleeper(100_000) + } + } + throw HIDBrokerNotReadyError( + diagnosticDescription: "Timed out waiting for another client to finish starting the broker.", + isSafeToReplaceBroker: false + ) + } + + static func acquireLifetimeLock(endpoint: String) throws -> Int32 { + try acquirePrivateLock(path: lifetimeLockPath(endpoint), operation: "lifetime lock", nonblocking: true) + } + + static func isBrokerProcessAlive(endpoint: String) throws -> Bool { + let descriptor: Int32 + do { + descriptor = try acquireLifetimeLock(endpoint: endpoint) + } catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == Int(EWOULDBLOCK) { + return true + } + _ = flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + return false + } + + private static func lifetimeLockPath(_ endpoint: String) -> String { + endpoint + ".lifetime.lock" + } + + private static func acquirePrivateLock( + path: String, + operation: String, + nonblocking: Bool + ) throws -> Int32 { + let descriptor = Darwin.open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw posixError("open " + operation) } + do { + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw posixError("fstat " + operation) } + guard (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_mode & (S_IRWXG | S_IRWXO) == 0 else { + throw CLIError(errorDescription: "HID broker " + operation + " is not a private owned file.") + } + let lockOperation = nonblocking ? LOCK_EX | LOCK_NB : LOCK_EX + while flock(descriptor, lockOperation) != 0 { + guard errno == EINTR else { throw posixError("lock " + operation) } + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + static func isBrokerUnavailable(_ error: Error) -> Bool { + if error is HIDBrokerNotReadyError { + return true + } + let error = error as NSError + return error.domain == NSPOSIXErrorDomain && + (error.code == Int(ECONNREFUSED) || error.code == Int(ENOENT)) + } + + static func exchange(_ requestData: Data, on descriptor: Int32) throws { + let response: HIDBrokerResponse + do { + try writeAll(requestData, to: descriptor) + let responseData = try readMessage(from: descriptor) + response = try JSONDecoder().decode(HIDBrokerResponse.self, from: responseData) + } catch { + throw CLIError(errorDescription: "HID request outcome is unknown and was not replayed: \(error.localizedDescription)") + } + if let error = response.error { + throw CLIError(errorDescription: error) + } + } + + static func waitForStaleEndpointShutdown(_ path: String) throws { + try waitForStaleEndpointShutdown( + path, + connector: connect(to:), + sleeper: { _ = usleep($0) }, + brokerIsAlive: { try isBrokerProcessAlive(endpoint: path) } + ) + } + + static func waitForStaleEndpointShutdown( + _ path: String, + deadline: UInt64, + monotonicNow: () -> UInt64, + sleeper: (useconds_t) -> Void + ) throws { + try waitForStaleEndpointShutdown( + path, + connector: connect(to:), + sleeper: sleeper, + brokerIsAlive: { try isBrokerProcessAlive(endpoint: path) }, + shouldContinue: { monotonicNow() < deadline } + ) + } + + static func waitForStaleEndpointShutdown( + _ path: String, + connector: (String) throws -> Int32, + sleeper: (useconds_t) -> Void, + brokerIsAlive: () throws -> Bool = { false }, + shouldContinue: () -> Bool = { true } + ) throws { + for _ in 0.. HIDBrokerSocketIdentity { + var info = stat() + guard lstat(path, &info) == 0 else { throw posixError("lstat") } + guard (info.st_mode & S_IFMT) == S_IFSOCK, info.st_uid == getuid() else { + throw CLIError(errorDescription: "Refusing to remove an unowned HID broker endpoint.") + } + return HIDBrokerSocketIdentity(device: info.st_dev, inode: info.st_ino) + } + + static func removeOwnedSocket( + _ path: String, + matching expectedIdentity: HIDBrokerSocketIdentity? = nil + ) throws { + do { + let identity = try socketIdentity(at: path) + if let expectedIdentity, identity != expectedIdentity { + return + } + } catch { + if (error as NSError).domain == NSPOSIXErrorDomain, + (error as NSError).code == Int(ENOENT) { + return + } + throw error + } + guard unlink(path) == 0 || errno == ENOENT else { throw posixError("unlink") } + } + + static func readMessage(from descriptor: Int32) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while data.count <= maximumMessageBytes { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + throw CLIError(errorDescription: "HID broker read timed out.") + } + throw posixError("read") + } + if count == 0 { break } + if let newline = buffer[...size)) + } + + static func configureSocketTimeouts( + _ descriptor: Int32, + readMilliseconds: Int, + writeMilliseconds: Int + ) throws { + try configureReceiveTimeout(descriptor, milliseconds: readMilliseconds) + + var writeTimeout = socketTimeout(milliseconds: writeMilliseconds) + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_SNDTIMEO, + &writeTimeout, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw posixError("setsockopt(SO_SNDTIMEO)") + } + } + + private static func configureReceiveTimeout(_ descriptor: Int32, milliseconds: Int) throws { + var readTimeout = socketTimeout(milliseconds: max(1, milliseconds)) + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_RCVTIMEO, + &readTimeout, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw posixError("setsockopt(SO_RCVTIMEO)") + } + } + + static func monotonicTimeNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + static func logDiagnostic(_ error: Error) { + guard let notReady = error as? HIDBrokerNotReadyError else { return } + diagnosticLogger.debug("\(notReady.diagnosticDescription, privacy: .public)") + } + + static func posixError(_ operation: String) -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [ + NSLocalizedDescriptionKey: "HID broker \(operation) failed: \(String(cString: strerror(errno)))" + ]) + } + + private static func makeAddress(path: String) throws -> sockaddr_un { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + let bytes = Array(path.utf8) + [0] + guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw CLIError(errorDescription: "HID broker socket path is too long.") + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: bytes) + } + return address + } + + static func connect(to path: String) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw posixError("socket") } + configureNoSignalPipe(descriptor) + var address = try makeAddress(path: path) + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let error = posixError("connect") + Darwin.close(descriptor) + throw error + } + do { + try configureSocketTimeouts( + descriptor, + readMilliseconds: clientResponseTimeoutMilliseconds, + writeMilliseconds: clientWriteTimeoutMilliseconds + ) + } catch { + Darwin.close(descriptor) + throw error + } + return descriptor + } + + private static func socketTimeout(milliseconds: Int) -> timeval { + timeval( + tv_sec: milliseconds / 1_000, + tv_usec: Int32((milliseconds % 1_000) * 1_000) + ) + } +} diff --git a/Sources/AXe/Utilities/HIDBroker.swift b/Sources/AXe/Utilities/HIDBroker.swift new file mode 100644 index 0000000..cd913c4 --- /dev/null +++ b/Sources/AXe/Utilities/HIDBroker.swift @@ -0,0 +1,393 @@ +import Darwin +import Foundation +import FBControlCore +import FBSimulatorControl + +struct HIDBrokerPrimitive: Codable, Equatable { + enum Kind: String, Codable { + case down + case up + case delay + } + + let kind: Kind + let x: Double? + let y: Double? + let duration: Double? + + static func touch(_ kind: Kind, x: Double, y: Double) -> Self { + Self(kind: kind, x: x, y: y, duration: nil) + } + + static func delay(_ duration: Double) -> Self { + Self(kind: .delay, x: nil, y: nil, duration: duration) + } +} + +private struct HIDBrokerRequest: Codable { + let primitives: [HIDBrokerPrimitive] +} + +struct HIDBrokerBootIdentity: Equatable { + let processIdentifier: pid_t + let startSeconds: UInt64 + let startMicroseconds: UInt64 +} + +enum HIDBroker { + static let inputDeliveryFailureDescription = "AXe could not deliver simulator input. The simulator may have restarted or disconnected. Confirm it is booted and try again." + static let dtuhidMinimumBootUptime: TimeInterval = 10 + private static let protocolVersion = 2 + static let maximumMessageBytes = 64 * 1024 + private static let idleTimeoutMilliseconds: Int32 = 60_000 + static let serverIOTimeoutMilliseconds: Int = 2_000 + static let clientWriteTimeoutMilliseconds: Int = 2_000 + static let clientResponseTimeoutMilliseconds: Int = 30_000 + static let startupAttempts = 300 + static let startupTimeoutNanoseconds: UInt64 = 30_000_000_000 + static func sendTouchPrimitives( + _ primitives: [HIDBrokerPrimitive], + simulatorUDID: String + ) throws { + let endpoint = try endpointPath(simulatorUDID: simulatorUDID) + try sendTouchPrimitives(primitives) { + try connectToReadyBroker(simulatorUDID: simulatorUDID, endpoint: endpoint) + } + } + + static func sendTouchPrimitives( + _ primitives: [HIDBrokerPrimitive], + descriptorProvider: () throws -> Int32 + ) throws { + let request = HIDBrokerRequest(primitives: primitives) + var requestData = try JSONEncoder().encode(request) + requestData.append(0x0A) + guard requestData.count <= maximumMessageBytes else { + throw CLIError(errorDescription: "HID broker request exceeds the maximum size.") + } + + // Recovery is limited to broker readiness. Once exchange starts, a lost response has an + // ambiguous outcome, so the request must never be reconnected or replayed. + let descriptor = try descriptorProvider() + defer { Darwin.close(descriptor) } + try exchange(requestData, on: descriptor) + } + + @MainActor + static func serve(simulatorUDID: String, logger: AxeLogger) async throws { + let endpoint = try endpointPath(simulatorUDID: simulatorUDID) + let listener = try makeListener(at: endpoint) + let endpointIdentity = try socketIdentity(at: endpoint) + defer { + Darwin.close(listener) + try? removeOwnedSocket(endpoint, matching: endpointIdentity) + } + + let bootIdentity = try currentBootIdentity(simulatorUDID: simulatorUDID) + let session = try await HIDInteractor.makeSession(for: simulatorUDID, logger: logger) + while true { + var pollDescriptor = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) + let result = Darwin.poll(&pollDescriptor, 1, idleTimeoutMilliseconds) + if result == 0 { + return + } + guard result > 0 else { + if errno == EINTR { continue } + throw posixError("poll") + } + + let client = Darwin.accept(listener, nil, nil) + guard client >= 0 else { + if errno == EINTR { continue } + throw posixError("accept") + } + configureNoSignalPipe(client) + try configureSocketTimeouts( + client, + readMilliseconds: serverIOTimeoutMilliseconds, + writeMilliseconds: serverIOTimeoutMilliseconds + ) + guard shouldReuseSession( + sessionBootIdentity: bootIdentity, + currentBootIdentity: try currentBootIdentity(simulatorUDID: simulatorUDID) + ) else { + try? writeHandshake(ready: false, to: client) + Darwin.close(client) + return + } + do { + try writeHandshake(ready: true, to: client) + } catch { + Darwin.close(client) + continue + } + let shouldContinue = await handle(client: client, session: session, logger: logger) + Darwin.close(client) + if !shouldContinue { + return + } + } + } + + @MainActor + private static func handle( + client: Int32, + session: HIDInteractor.Session, + logger: AxeLogger + ) async -> Bool { + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(client, &peerUID, &peerGID) == 0, peerUID == getuid() else { + try? writeResponse(error: "HID broker rejected a client owned by another user.", to: client) + return true + } + + var shouldContinue = true + do { + let data = try readMessage(from: client) + let request = try JSONDecoder().decode(HIDBrokerRequest.self, from: data) + for primitive in request.primitives { + switch primitive.kind { + case .down, .up: + guard let x = primitive.x, let y = primitive.y else { + throw CLIError(errorDescription: "Touch primitive is missing coordinates.") + } + let direction: FBSimulatorHIDDirection = primitive.kind == .down ? .down : .up + do { + try await HIDInteractor.performHIDEvent( + .touch(direction: direction, x: x, y: y), + in: session, + logger: logger + ) + } catch { + shouldContinue = false + throw error + } + case .delay: + guard let duration = primitive.duration, duration >= 0, duration <= 10 else { + throw CLIError(errorDescription: "HID broker delay is invalid.") + } + try await Task.sleep(for: .seconds(duration)) + } + } + try writeResponse(error: nil, to: client) + } catch { + logger.error().log("HID broker request failed: \(error.localizedDescription)") + try? writeResponse(error: brokerResponseDescription(for: error), to: client) + } + return shouldContinue + } + + static func brokerResponseDescription(for error: Error) -> String { + if let userFacingError = error as? UserFacingError { + return userFacingError.userFacingDescription + } + return inputDeliveryFailureDescription + } + + static func endpointPath(simulatorUDID: String) throws -> String { + let developerDirectory = try FBXcodeDirectory.resolveDeveloperDirectory() + return try endpointPath(simulatorUDID: simulatorUDID, developerDirectory: developerDirectory) + } + + static func endpointPath(simulatorUDID: String, developerDirectory: String) throws -> String { + let uid = getuid() + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .resolvingSymlinksInPath() + .appendingPathComponent("axe-hid-\(uid)", isDirectory: true) + try ensurePrivateDirectory(root.path, uid: uid) + let developerDirectory = URL(fileURLWithPath: developerDirectory, isDirectory: true) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + let identity = String(fnv1a64(developerDirectory), radix: 16) + let simulatorIdentity = String(fnv1a64(simulatorUDID), radix: 16) + // Version the endpoint so a running broker from an older wire protocol cannot intercept + // a request before the current client completes its readiness handshake. + let filename = "\(simulatorIdentity)-\(identity)-v\(protocolVersion).sock" + let path = root.appendingPathComponent(filename).path + guard path.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { + throw CLIError(errorDescription: "HID broker socket path is too long.") + } + return path + } + + private static func ensurePrivateDirectory(_ path: String, uid: uid_t) throws { + var info = stat() + if lstat(path, &info) == 0 { + guard (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == uid, + info.st_mode & (S_IRWXG | S_IRWXO) == 0 else { + throw CLIError(errorDescription: "HID broker directory is not a private owned directory.") + } + return + } + guard errno == ENOENT else { throw posixError("lstat") } + guard mkdir(path, S_IRWXU) == 0 || errno == EEXIST else { throw posixError("mkdir") } + guard lstat(path, &info) == 0 else { throw posixError("lstat") } + guard (info.st_mode & S_IFMT) == S_IFDIR, info.st_uid == uid else { + throw CLIError(errorDescription: "HID broker directory is not a private owned directory.") + } + guard chmod(path, S_IRWXU) == 0 else { throw posixError("chmod") } + guard lstat(path, &info) == 0 else { throw posixError("lstat") } + guard (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == uid, + info.st_mode & (S_IRWXG | S_IRWXO) == 0 else { + throw CLIError(errorDescription: "HID broker directory is not a private owned directory.") + } + } + + private static func spawnBroker(simulatorUDID: String) throws { + guard let executable = Bundle.main.executableURL else { + throw CLIError(errorDescription: "Unable to locate the AXe executable for the HID broker.") + } + let process = Process() + process.executableURL = executable + process.arguments = ["hid-broker", "--udid", simulatorUDID] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + } + + private static func connectToReadyBroker(simulatorUDID: String, endpoint: String) throws -> Int32 { + do { + return try connectToReadyBroker( + simulatorUDID: simulatorUDID, + endpoint: endpoint, + connector: connect(to:), + spawner: spawnBroker(simulatorUDID:), + sleeper: { _ = usleep($0) } + ) + } catch { + logDiagnostic(error) + throw error + } + } + + static func connectToReadyBroker( + simulatorUDID: String, + endpoint: String, + connector: (String) throws -> Int32, + spawner: (String) throws -> Void, + sleeper: (useconds_t) -> Void, + monotonicNow: () -> UInt64 = monotonicTimeNanoseconds + ) throws -> Int32 { + let startedAt = monotonicNow() + let deadlineResult = startedAt.addingReportingOverflow(startupTimeoutNanoseconds) + let deadline = deadlineResult.overflow ? UInt64.max : deadlineResult.partialValue + func handshakeTimeout() throws -> Int { + let now = monotonicNow() + guard now < deadline else { + throw HIDBrokerNotReadyError(diagnosticDescription: "The broker startup deadline expired.") + } + let remainingMilliseconds = max(1, Int((deadline - now) / 1_000_000)) + return min(serverIOTimeoutMilliseconds, remainingMilliseconds) + } + + do { + return try connectAndAwaitHandshake( + endpoint: endpoint, + connector: connector, + timeoutMilliseconds: handshakeTimeout() + ) + } catch { + guard isBrokerUnavailable(error) else { throw error } + } + // Hold the endpoint lock until the listener accepts connections so concurrent cold-start + // clients wait for this broker instead of spawning competitors. + let lockDescriptor = try acquireStartupLock( + endpoint: endpoint, + deadline: deadline, + monotonicNow: monotonicNow, + sleeper: sleeper + ) + defer { + _ = flock(lockDescriptor, LOCK_UN) + Darwin.close(lockDescriptor) + } + do { + return try connectAndAwaitHandshake( + endpoint: endpoint, + connector: connector, + timeoutMilliseconds: handshakeTimeout() + ) + } catch { + guard isBrokerUnavailable(error) else { throw error } + } + var didSpawnBroker = false + if try !isBrokerProcessAlive(endpoint: endpoint) { + try waitForStaleEndpointShutdown( + endpoint, + deadline: deadline, + monotonicNow: monotonicNow, + sleeper: sleeper + ) + try spawner(simulatorUDID) + didSpawnBroker = true + } + var lastError: Error? + for _ in 0.. UInt64 { + string.utf8.reduce(14_695_981_039_346_656_037) { hash, byte in + (hash ^ UInt64(byte)) &* 1_099_511_628_211 + } + } + +} diff --git a/Sources/AXe/Utilities/HIDBrokerReadiness.swift b/Sources/AXe/Utilities/HIDBrokerReadiness.swift new file mode 100644 index 0000000..a650f3b --- /dev/null +++ b/Sources/AXe/Utilities/HIDBrokerReadiness.swift @@ -0,0 +1,84 @@ +import Darwin +import Foundation +import FBControlCore + +extension HIDBroker { + static func currentBootIdentity(simulatorUDID: String) throws -> HIDBrokerBootIdentity { + let processes = FBProcessFetcher().processes(withProcessName: "launchd_sim").filter { + $0.arguments.contains { $0.contains(simulatorUDID) } + } + let identities = processes.compactMap { bootIdentity(processIdentifier: $0.processIdentifier) } + guard let identity = newestBootIdentity(identities) else { + throw CLIError( + errorDescription: "Simulator \(simulatorUDID) is not ready for input. Wait for it to finish booting and try again." + ) + } + + return identity + } + + private static func bootIdentity(processIdentifier: pid_t) -> HIDBrokerBootIdentity? { + var processInfo = proc_bsdinfo() + let expectedSize = MemoryLayout.size + let actualSize = proc_pidinfo( + processIdentifier, + PROC_PIDTBSDINFO, + 0, + &processInfo, + Int32(expectedSize) + ) + guard actualSize == Int32(expectedSize) else { return nil } + + return HIDBrokerBootIdentity( + processIdentifier: processIdentifier, + startSeconds: processInfo.pbi_start_tvsec, + startMicroseconds: processInfo.pbi_start_tvusec + ) + } + + static func newestBootIdentity(_ identities: [HIDBrokerBootIdentity]) -> HIDBrokerBootIdentity? { + identities.max { lhs, rhs in + if lhs.startSeconds != rhs.startSeconds { + return lhs.startSeconds < rhs.startSeconds + } + if lhs.startMicroseconds != rhs.startMicroseconds { + return lhs.startMicroseconds < rhs.startMicroseconds + } + return lhs.processIdentifier < rhs.processIdentifier + } + } + + static func dtuhidReadinessDelay( + bootIdentity: HIDBrokerBootIdentity, + now: Date, + minimumUptime: TimeInterval = dtuhidMinimumBootUptime + ) -> TimeInterval { + let startTime = TimeInterval(bootIdentity.startSeconds) + + (TimeInterval(bootIdentity.startMicroseconds) / 1_000_000) + let uptime = max(0, now.timeIntervalSince1970 - startTime) + return max(0, minimumUptime - uptime) + } + + static func waitForHIDReadiness( + bootIdentity: HIDBrokerBootIdentity, + isDTUHIDSelected: Bool, + now: () -> Date, + sleep: (TimeInterval) async throws -> Void + ) async throws { + guard isDTUHIDSelected else { + return + } + let delay = dtuhidReadinessDelay(bootIdentity: bootIdentity, now: now()) + guard delay > 0 else { + return + } + try await sleep(delay) + } + + static func shouldReuseSession( + sessionBootIdentity: HIDBrokerBootIdentity, + currentBootIdentity: HIDBrokerBootIdentity + ) -> Bool { + sessionBootIdentity == currentBootIdentity + } +} diff --git a/Sources/AXe/Utilities/HIDInteractor.swift b/Sources/AXe/Utilities/HIDInteractor.swift index 4b83c6f..f37420f 100644 --- a/Sources/AXe/Utilities/HIDInteractor.swift +++ b/Sources/AXe/Utilities/HIDInteractor.swift @@ -1,9 +1,13 @@ import Foundation import FBControlCore import FBSimulatorControl -import ObjectiveC // MARK: - HID Interactor + + +// Xcode 27 Beta 3 (27A5218g) Device Hub requires Resize Mode to be disabled for UI automation. +// With Resize Mode enabled, both DTUHID and legacy Indigo reported successful sends without +// delivering touches; with it disabled, the ui-interactions pass through this IDB path. @MainActor struct HIDInteractor { @@ -13,11 +17,8 @@ struct HIDInteractor { let hid: FBSimulatorHID } - - // Cache for HID connections per simulator private static var hidConnections: [String: FBSimulatorHID] = [:] - /// Configurable stabilization delay to ensure HID events are fully processed /// Can be set via AXE_HID_STABILIZATION_MS environment variable private static var stabilizationDelayMs: UInt64 { @@ -36,32 +37,64 @@ struct HIDInteractor { logger.info().log("Private frameworks loaded successfully.") } catch { logger.error().log("Failed to load private frameworks: \(error)") - throw CLIError(errorDescription: "SimulatorKit is required for HID interactions. Error: \(error)") + throw CLIError( + errorDescription: "AXe could not initialize simulator input using the selected Xcode installation. Confirm Xcode 26 or later is selected and try again." + ) } let simulatorSet = try await getSimulatorSet(deviceSetPath: nil, logger: logger, reporter: EmptyEventReporter.shared) logger.info().log("FBSimulatorSet obtained.") guard let simulator = simulatorSet.allSimulators.first(where: { $0.udid == simulatorUDID }) else { - throw CLIError(errorDescription: "Simulator with UDID \(simulatorUDID) not found in set.") + throw CLIError.simulatorNotFound(udid: simulatorUDID) } logger.info().log("Target (FBSimulator) obtained: \(simulator.udid)") logger.info().log("Simulator name: \(simulator.name)") guard simulator.state == .booted else { - throw CLIError(errorDescription: "Simulator with UDID \(simulatorUDID) is not booted. Current state: \(simulator.state)") + let stateDescription = FBiOSTargetStateStringFromState(simulator.state) + throw CLIError( + errorDescription: "Simulator \(simulatorUDID) is not booted. Current state: \(stateDescription)." + ) } logger.info().log("Simulator state verified: booted") + let bootIdentity = try HIDBroker.currentBootIdentity(simulatorUDID: simulatorUDID) + let isDTUHIDSelected = FBXcodeConfiguration.xcodeVersion.majorVersion >= 27 || + FBProcessFetcher().subprocess( + of: bootIdentity.processIdentifier, + withName: "dtuhidd" + ) > 0 + try await HIDBroker.waitForHIDReadiness( + bootIdentity: bootIdentity, + isDTUHIDSelected: isDTUHIDSelected, + now: Date.init, + sleep: { delay in try await Task.sleep(for: .seconds(delay)) } + ) let hid = try await getOrCreateHIDConnection(for: simulator, logger: logger) + let connectedBootIdentity = try HIDBroker.currentBootIdentity(simulatorUDID: simulatorUDID) + guard HIDBroker.shouldReuseSession( + sessionBootIdentity: bootIdentity, + currentBootIdentity: connectedBootIdentity + ) else { + hidConnections.removeValue(forKey: simulatorUDID) + throw CLIError( + errorDescription: "Simulator \(simulatorUDID) restarted while AXe was connecting. Try the command again." + ) + } + try await HIDBroker.waitForHIDReadiness( + bootIdentity: connectedBootIdentity, + isDTUHIDSelected: hid.transportType == .dtuhid, + now: Date.init, + sleep: { delay in try await Task.sleep(for: .seconds(delay)) } + ) return Session(simulatorUDID: simulatorUDID, simulator: simulator, hid: hid) } static func performHIDEvent(_ event: FBSimulatorHIDEvent, in session: Session, logger: AxeLogger) async throws { logger.info().log("Performing HID event...") - let eventFuture = event.perform(on: session.hid) - _ = try await FutureBridge.value(eventFuture) + try await session.hid.send(event: event, logger: logger) logger.info().log("HID event performed successfully.") if stabilizationDelayMs > 0 { @@ -96,19 +129,19 @@ struct HIDInteractor { let movePoints = try compositeDragMovePoints(from: start, to: end, steps: steps) let stepDelay = duration / Double(steps) var events: [FBSimulatorHIDEvent] = [ - .touchDownAt(x: start.x, y: start.y), + .touch(direction: .down, x: start.x, y: start.y), .delay(initialHold) ] for point in movePoints { events.append(.delay(stepDelay)) - events.append(try touchMoveEvent(x: point.x, y: point.y)) + events.append(.touch(direction: .down, x: point.x, y: point.y)) } events.append(.delay(finalHold)) - events.append(.touchUpAt(x: end.x, y: end.y)) + events.append(.touch(direction: .up, x: end.x, y: end.y)) - return FBSimulatorHIDEvent(events: events) + return .composite(events) } static func compositeDragMovePoints( @@ -129,19 +162,6 @@ struct HIDInteractor { } } - private static func touchMoveEvent(x: Double, y: Double) throws -> FBSimulatorHIDEvent { - typealias TouchMoveIMP = @convention(c) (AnyClass, Selector, Double, Double) -> FBSimulatorHIDEvent - - let selector = NSSelectorFromString("touchMoveAtX:y:") - guard let method = class_getClassMethod(FBSimulatorHIDEvent.self, selector) else { - throw CLIError(errorDescription: "FBSimulatorHIDEvent does not support touch move events.") - } - - let implementation = method_getImplementation(method) - let touchMove = unsafeBitCast(implementation, to: TouchMoveIMP.self) - return touchMove(FBSimulatorHIDEvent.self, selector, x, y) - } - static func performCompositeDrag( from start: (x: Double, y: Double), to end: (x: Double, y: Double), @@ -186,8 +206,8 @@ struct HIDInteractor { try await Task.sleep(for: .seconds(preDelay)) } - let touchDownEvent = FBSimulatorHIDEvent.touchDownAt(x: point.x, y: point.y) - let touchUpEvent = FBSimulatorHIDEvent.touchUpAt(x: point.x, y: point.y) + let touchDownEvent = FBSimulatorHIDEvent.touch(direction: .down, x: point.x, y: point.y) + let touchUpEvent = FBSimulatorHIDEvent.touch(direction: .up, x: point.x, y: point.y) var didTouchDown = false do { @@ -198,6 +218,8 @@ struct HIDInteractor { didTouchDown = false } catch { if didTouchDown { + // Never replay touch-down after an ambiguous failure. A best-effort touch-up can + // only release possible held state; it cannot produce a second tap by itself. try? await performHIDEvent(touchUpEvent, in: session, logger: logger) } throw error @@ -209,7 +231,6 @@ struct HIDInteractor { } } - // Get or create a cached HID connection (matching CompanionLib's connectToHID behavior) private static func getOrCreateHIDConnection(for simulator: FBSimulator, logger: AxeLogger) async throws -> FBSimulatorHID { if let existingHID = hidConnections[simulator.udid] { @@ -218,8 +239,7 @@ struct HIDInteractor { } logger.info().log("Creating new HID connection for simulator \(simulator.udid)...") - let hidFuture = simulator.connectToHID() - let hid = try await FutureBridge.value(hidFuture) + let hid = try await simulator.connectToHID() hidConnections[simulator.udid] = hid logger.info().log("HID connection created and cached for simulator \(simulator.udid)") @@ -230,4 +250,4 @@ struct HIDInteractor { static func clearHIDConnections() { hidConnections.removeAll() } -} +} diff --git a/Sources/AXe/Utilities/ShellTokenizer.swift b/Sources/AXe/Utilities/ShellTokenizer.swift index 6746e74..800a247 100644 --- a/Sources/AXe/Utilities/ShellTokenizer.swift +++ b/Sources/AXe/Utilities/ShellTokenizer.swift @@ -1,7 +1,7 @@ import Foundation struct ShellTokenizer { - enum TokenizerError: LocalizedError { + enum TokenizerError: LocalizedError, UserFacingError { case unterminatedSingleQuote case unterminatedDoubleQuote case danglingEscape @@ -16,6 +16,10 @@ struct ShellTokenizer { return "Dangling escape sequence in batch step." } } + + var userFacingDescription: String { + errorDescription ?? "AXe could not parse the batch step." + } } static func tokenize(_ line: String) throws -> [String] { @@ -147,4 +151,3 @@ struct ShellTokenizer { return tokens } } - diff --git a/Sources/AXe/Utilities/TextToHIDEvents.swift b/Sources/AXe/Utilities/TextToHIDEvents.swift index b096346..3de5494 100644 --- a/Sources/AXe/Utilities/TextToHIDEvents.swift +++ b/Sources/AXe/Utilities/TextToHIDEvents.swift @@ -6,7 +6,7 @@ import FBSimulatorControl struct TextToHIDEvents { // MARK: - Error Types - enum TextConversionError: Error, LocalizedError { + enum TextConversionError: Error, LocalizedError, UserFacingError { case unsupportedCharacter(Character) var errorDescription: String? { @@ -15,6 +15,10 @@ struct TextToHIDEvents { return "No keycode found for character: '\(char)'" } } + + var userFacingDescription: String { + errorDescription ?? "AXe could not convert the requested text into simulator keyboard input." + } } // MARK: - Simple Key Event Creation @@ -22,18 +26,18 @@ struct TextToHIDEvents { /// Creates key events for a character that doesn't require shift private static func simpleKeyEvent(keyCode: Int) -> [FBSimulatorHIDEvent] { return [ - .keyDown(UInt32(keyCode)), - .keyUp(UInt32(keyCode)) + .keyboard(direction: .down, keyCode: UInt32(keyCode)), + .keyboard(direction: .up, keyCode: UInt32(keyCode)) ] } /// Creates key events for a character that requires shift private static func shiftedKeyEvent(keyCode: Int) -> [FBSimulatorHIDEvent] { return [ - .keyDown(225), // Left Shift key down - .keyDown(UInt32(keyCode)), // Target key down - .keyUp(UInt32(keyCode)), // Target key up - .keyUp(225) // Left Shift key up + .keyboard(direction: .down, keyCode: 225), + .keyboard(direction: .down, keyCode: UInt32(keyCode)), + .keyboard(direction: .up, keyCode: UInt32(keyCode)), + .keyboard(direction: .up, keyCode: 225) ] } @@ -86,4 +90,4 @@ struct TextToHIDEvents { return events } -} \ No newline at end of file +} diff --git a/Sources/AXe/main.swift b/Sources/AXe/main.swift index 0b41ab4..47efd0d 100644 --- a/Sources/AXe/main.swift +++ b/Sources/AXe/main.swift @@ -31,7 +31,8 @@ struct Axe: AsyncParsableCommand { StreamVideo.self, RecordVideo.self, Screenshot.self, - Batch.self + Batch.self, + HIDBrokerCommand.self ] ) } diff --git a/Tests/AccessibilityFetcherTests.swift b/Tests/AccessibilityFetcherTests.swift new file mode 100644 index 0000000..47dc327 --- /dev/null +++ b/Tests/AccessibilityFetcherTests.swift @@ -0,0 +1,462 @@ +import Foundation +import FBControlCore +import Testing +@testable import AXe + +@Suite("Accessibility Fetcher Tests") +@MainActor +struct AccessibilityFetcherTests { + @Test("Serializes nested accessibility arrays without changing their schema") + func serializesNestedAccessibilityArrays() throws { + let hierarchy: [[String: Any]] = [ + [ + "AXLabel": "Fixture", + "type": "Application", + "frame": ["x": 0, "y": 0, "width": 390, "height": 844], + "traits": ["Button"], + "children": [["AXLabel": "Tap Count: 0", "type": "StaticText"]], + ], + ] + + let data = try AccessibilityFetcher.serializeAccessibilityInfo(hierarchy) + let serialized = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + let root = try #require(serialized.first) + + #expect(root["AXLabel"] as? String == "Fixture") + #expect(root["type"] as? String == "Application") + #expect(root["traits"] as? [String] == ["Button"]) + #expect((root["children"] as? [[String: Any]])?.first?["AXLabel"] as? String == "Tap Count: 0") + #expect(root["elements"] == nil) + } + + @Test("Requests the complete legacy accessibility compatibility key set") + func requestsLegacyAccessibilityCompatibilityKeys() { + #expect(AccessibilityFetcher.accessibilityOutputKeys == [ + .label, + .frame, + .value, + .uniqueID, + .type, + .title, + .frameDict, + .help, + .enabled, + .customActions, + .role, + .roleDescription, + .subrole, + .contentRequired, + .pid, + .traits, + ]) + #expect(!AccessibilityFetcher.accessibilityRequestKeys.contains(.traits)) + #expect( + AccessibilityFetcher.accessibilityRequestKeys.union([.traits]) + == AccessibilityFetcher.accessibilityOutputKeys + ) + } + + @Test("Defaults unavailable traits recursively without changing non-element dictionaries") + func defaultsUnavailableTraitsRecursively() throws { + let hierarchy: [[String: Any]] = [[ + "type": "Application", + "frame": ["x": 0, "y": 0, "width": 390, "height": 844], + "children": [["type": "Button", "traits": ["Button"]]], + ]] + + let compatible = try #require( + AccessibilityFetcher.addingCompatibilityDefaults(to: hierarchy) as? [[String: Any]] + ) + let root = try #require(compatible.first) + let frame = try #require(root["frame"] as? [String: Any]) + let child = try #require((root["children"] as? [[String: Any]])?.first) + + #expect(root["traits"] as? [String] == []) + #expect(frame["traits"] == nil) + #expect(child["traits"] as? [String] == ["Button"]) + } + + @Test("Distinguishes transient root-only responses from populated hierarchies") + func detectsAccessibilityDescendants() throws { + let rootOnly = try JSONSerialization.data(withJSONObject: [[ + "type": "Application", + "children": [], + ]]) + let populated = try JSONSerialization.data(withJSONObject: [[ + "type": "Application", + "children": [["type": "Button", "children": []]], + ]]) + + #expect(try !AccessibilityFetcher.containsAccessibilityDescendant(in: rootOnly)) + #expect(try AccessibilityFetcher.containsAccessibilityDescendant(in: populated)) + } + + @Test("Serializes point lookup dictionaries without adding a response envelope") + func serializesPointLookupDictionary() throws { + let element: [String: Any] = [ + "AXLabel": "Tap Count: 0", + "AXUniqueId": "tap-count", + "type": "StaticText", + ] + + let data = try AccessibilityFetcher.serializeAccessibilityInfo(element) + let serialized = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(serialized["AXLabel"] as? String == "Tap Count: 0") + #expect(serialized["AXUniqueId"] as? String == "tap-count") + #expect(serialized["elements"] == nil) + } + + @Test("Classifies a screen-sized group as a transient point fallback") + func classifiesTransientPointFallback() throws { + let portraitFallback = try JSONSerialization.data(withJSONObject: [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ]) + let landscapeFallback = try JSONSerialization.data(withJSONObject: [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 0, "y": 0, "width": 874, "height": 402], + ]) + let button = try JSONSerialization.data(withJSONObject: [ + "type": "Button", + "role": "AXButton", + "frame": ["x": 16, "y": 62, "width": 44, "height": 44], + ]) + let point = AccessibilityPoint(x: 38, y: 84) + + #expect(try AccessibilityFetcher.isTransientPointFallback(in: portraitFallback, at: point)) + #expect(try AccessibilityFetcher.isTransientPointFallback(in: landscapeFallback, at: point)) + #expect(try !AccessibilityFetcher.isTransientPointFallback(in: button, at: point)) + } + + @Test("Preserves legitimate compact groups returned by point lookup") + func preservesCompactPointGroup() throws { + let group = try JSONSerialization.data(withJSONObject: [[ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 20, "y": 60, "width": 120, "height": 80], + ]]) + + #expect(try !AccessibilityFetcher.isTransientPointFallback( + in: group, + at: AccessibilityPoint(x: 40, y: 80) + )) + } + + @Test("Preserves meaningful large groups returned by point lookup") + func preservesMeaningfulLargePointGroups() throws { + let point = AccessibilityPoint(x: 40, y: 80) + let variants: [[String: Any]] = [ + [ + "type": "Group", + "role": "AXGroup", + "AXLabel": "Content", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ], + [ + "type": "Group", + "role": "AXGroup", + "title": "Content", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ], + [ + "type": "Group", + "role": "AXGroup", + "help": "Interactive content", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ], + [ + "type": "Group", + "role": "AXGroup", + "custom_actions": ["Activate"], + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ], + [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + "children": [["type": "Button"]], + ], + [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 20, "y": 40, "width": 360, "height": 700], + ], + ] + + for variant in variants { + let data = try JSONSerialization.data(withJSONObject: variant) + #expect(try !AccessibilityFetcher.isTransientPointFallback(in: data, at: point)) + } + } + + @Test("Retries transient point fallbacks until a target appears") + func retriesTransientPointFallback() async throws { + let fallback = try JSONSerialization.data(withJSONObject: [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ]) + let button = try JSONSerialization.data(withJSONObject: [ + "type": "Button", + "role": "AXButton", + "frame": ["x": 16, "y": 62, "width": 44, "height": 44], + ]) + var responses = [fallback, button] + var waits: [Duration] = [] + + let result = try await AccessibilityFetcher.retryingTransientPointFallback( + at: AccessibilityPoint(x: 38, y: 84), + fetch: { responses.removeFirst() }, + wait: { waits.append($0) } + ) + + #expect(result == button) + #expect(responses.isEmpty) + #expect(waits == [.milliseconds(50)]) + } + + @Test("Bounds transient point fallback retries and preserves the final response") + func boundsTransientPointFallbackRetries() async throws { + let fallback = try JSONSerialization.data(withJSONObject: [ + "type": "Group", + "role": "AXGroup", + "frame": ["x": 0, "y": 0, "width": 402, "height": 874], + ]) + var fetchCount = 0 + var waitCount = 0 + + let result = try await AccessibilityFetcher.retryingTransientPointFallback( + at: AccessibilityPoint(x: 38, y: 84), + fetch: { + fetchCount += 1 + return fallback + }, + wait: { _ in waitCount += 1 } + ) + + #expect(result == fallback) + #expect(fetchCount == 5) + #expect(waitCount == 4) + } + + @Test("Returns point targets without retrying") + func returnsPointTargetWithoutRetrying() async throws { + let button = try JSONSerialization.data(withJSONObject: [ + "type": "Button", + "role": "AXButton", + "frame": ["x": 16, "y": 62, "width": 44, "height": 44], + ]) + var fetchCount = 0 + + let result = try await AccessibilityFetcher.retryingTransientPointFallback( + at: AccessibilityPoint(x: 38, y: 84), + fetch: { + fetchCount += 1 + return button + }, + wait: { _ in Issue.record("A non-fallback target should not wait") } + ) + + #expect(result == button) + #expect(fetchCount == 1) + } + + @Test("Rejects accessibility payloads that are not dictionaries or arrays") + func rejectsInvalidAccessibilityPayload() { + #expect(throws: CLIError.self) { + try AccessibilityFetcher.serializeAccessibilityInfo("invalid") + } + } + + @Test("Restarts the canonical testmanagerd service with direct simctl arguments") + func restartsCanonicalTestManagerService() async throws { + var executableURL: URL? + var arguments: [String] = [] + var timeout: TimeInterval? + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { receivedURL, receivedArguments, receivedTimeout in + executableURL = receivedURL + arguments = receivedArguments + timeout = receivedTimeout + return 0 + }, + wait: { duration in waits.append(duration) } + ) + + try await AccessibilityFetcher.recoverTestManagerDaemon( + simulatorUDID: "TEST-UDID", + dependencies: dependencies + ) + + #expect(executableURL?.path == "/usr/bin/xcrun") + #expect(arguments == [ + "simctl", + "spawn", + "TEST-UDID", + "launchctl", + "kickstart", + "-k", + "user/foreground/com.apple.testmanagerd", + ]) + #expect(timeout == 3) + #expect(waits == [.milliseconds(250)]) + } + + @Test("Cancelling recovery process polling terminates promptly") + func cancellationStopsRecoveryProcess() async throws { + let startedAt = ContinuousClock.now + let task = Task { + try await AccessibilityFetcher.runProcess( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["10"], + timeout: 10 + ) + } + + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(startedAt.duration(to: .now) < .seconds(1)) + } + + @Test("Classifies only confirmed accessibility channel failures as recoverable") + func classifiesRecoverableChannelFailures() { + let disconnected = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] + ) + let fileDescriptorExhaustion = NSError( + domain: "DTXConnectionServicesErrorDomain", + code: 24, + userInfo: nil + ) + let unrelatedPOSIXError = NSError(domain: NSPOSIXErrorDomain, code: 24) + let unrelatedDTXError = NSError(domain: "DTXConnectionServicesErrorDomain", code: 7) + let nearMatch = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Previous channel disconnected unexpectedly"] + ) + let normalizedMatch = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: " CHANNEL\n DISCONNECTED "] + ) + + #expect(AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: disconnected)) + #expect(AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: normalizedMatch)) + #expect(AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: fileDescriptorExhaustion)) + #expect(!AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: unrelatedPOSIXError)) + #expect(!AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: unrelatedDTXError)) + #expect(!AccessibilityFetcher.shouldRecoverTestManagerDaemon(from: nearMatch)) + } + + @Test("Recovers and reacquires the frontmost hierarchy once") + func retriesFrontmostHierarchyOnce() async throws { + var operationCount = 0 + var recoveryCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + recoveryCount += 1 + return 0 + }, + wait: { _ in } + ) + let disconnected = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] + ) + + let result = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + simulatorUDID: "TEST-UDID", + logger: AxeLogger(), + dependencies: dependencies + ) { + operationCount += 1 + if operationCount == 1 { + throw disconnected + } + return "reacquired" + } + + #expect(result == "reacquired") + #expect(operationCount == 2) + #expect(recoveryCount == 1) + } + + @Test("Does not loop when the retry also disconnects") + func boundsRecoveryToOneRetry() async { + var operationCount = 0 + var recoveryCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + recoveryCount += 1 + return 0 + }, + wait: { _ in } + ) + let disconnected = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + simulatorUDID: "TEST-UDID", + logger: AxeLogger(), + dependencies: dependencies + ) { + operationCount += 1 + throw disconnected + } as String + Issue.record("Expected the retry to fail") + } catch { + #expect(error.localizedDescription == "Channel disconnected") + } + + #expect(operationCount == 2) + #expect(recoveryCount == 1) + } + + @Test("Preserves unrelated errors without recovery") + func preservesUnrelatedErrors() async { + var recoveryCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + recoveryCount += 1 + return 0 + }, + wait: { _ in } + ) + let unrelated = NSError( + domain: "Accessibility", + code: 99, + userInfo: [NSLocalizedDescriptionKey: "Unrelated failure"] + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + simulatorUDID: "TEST-UDID", + logger: AxeLogger(), + dependencies: dependencies + ) { + throw unrelated + } as String + Issue.record("Expected the operation to fail") + } catch { + #expect(error.localizedDescription == "Unrelated failure") + } + + #expect(recoveryCount == 0) + } +} diff --git a/Tests/ButtonTests.swift b/Tests/ButtonTests.swift index 63d8d00..bb46f43 100644 --- a/Tests/ButtonTests.swift +++ b/Tests/ButtonTests.swift @@ -3,6 +3,109 @@ import Foundation @Suite("Button Command Tests", .serialized, .enabled(if: isE2EEnabled)) struct ButtonTests { + private func simulatorUDID() throws -> String { + try #require(defaultSimulatorUDID, "AXE_E2E_SIMULATOR_UDID is required for button E2E tests") + } + + private func springBoardState(_ name: String) async throws -> Int { + let simulatorUDID = try simulatorUDID() + let result = try await CommandRunner.run( + "xcrun simctl spawn \(simulatorUDID) notifyutil -g com.apple.springboard.\(name)" + ) + return try #require( + result.output.split(whereSeparator: { $0.isWhitespace }).last.flatMap { Int($0) }, + "SpringBoard \(name) state must be observable" + ) + } + + private func waitForSpringBoardState(_ name: String, expectedState: Int) async throws -> Int { + let deadline = Date().addingTimeInterval(5) + var currentState = try await springBoardState(name) + while currentState != expectedState && Date() < deadline { + try await Task.sleep(for: .milliseconds(100)) + currentState = try await springBoardState(name) + } + return currentState + } + + private func isXcode27() async throws -> Bool { + let version = try await CommandRunner.run("xcodebuild -version") + let majorVersion = version.output + .split(whereSeparator: { $0.isWhitespace }) + .dropFirst() + .first + .flatMap { Int($0.split(separator: ".").first ?? "") } + return majorVersion == 27 + } + + private func waitForDeviceHIDServiceIfRequired() async throws { + guard try await isXcode27() else { + return + } + + let simulatorUDID = try simulatorUDID() + let deadline = Date().addingTimeInterval(10) + while Date() < deadline { + let result = try await CommandRunner.run( + "launchd_sim_pid=$(pgrep -f '^launchd_sim .*/Devices/\(simulatorUDID)/data/var/run/launchd_bootstrap.plist$' | head -1); " + + "test -n \"$launchd_sim_pid\" && pgrep -P \"$launchd_sim_pid\" -x dtuhidd", + allowFailure: true + ) + if result.exitCode == 0 { + return + } + try await Task.sleep(for: .milliseconds(100)) + } + + throw TestError.unexpectedState("Device Hub simulator DTUHID service did not become ready") + } + + private func waitForDeviceHIDAttachmentAfterBootIfRequired() async throws { + try await waitForDeviceHIDServiceIfRequired() + guard try await isXcode27() else { + return + } + try await Task.sleep(for: .seconds(10)) + } + + private func rebootSimulator() async throws { + let simulatorUDID = try simulatorUDID() + _ = try await CommandRunner.run("xcrun simctl shutdown \(simulatorUDID)", allowFailure: true) + _ = try await CommandRunner.run("xcrun simctl boot \(simulatorUDID)") + _ = try await CommandRunner.run("xcrun simctl bootstatus \(simulatorUDID) -b", timeout: 120) + try await waitForDeviceHIDAttachmentAfterBootIfRequired() + #expect( + try await waitForSpringBoardState("lockstate", expectedState: 0) == 0, + "A freshly booted simulator should be unlocked" + ) + } + + private func prepareUnlockedSimulator() async throws { + if try await springBoardState("lockstate") != 0 { + try await rebootSimulator() + } else { + try await waitForDeviceHIDServiceIfRequired() + } + } + + private func runButtonCommand(_ command: String) async throws -> Duration { + try await prepareUnlockedSimulator() + let startTime = ContinuousClock.now + do { + try await TestHelpers.runAxeCommand(command, simulatorUDID: defaultSimulatorUDID) + } catch { + try? await rebootSimulator() + throw error + } + return ContinuousClock.now - startTime + } + + private func pressLockingButton(_ command: String) async throws { + _ = try await runButtonCommand(command) + #expect(try await waitForSpringBoardState("lockstate", expectedState: 1) == 1) + try await rebootSimulator() + } + @Test("Home button press") func homeButtonPress() async throws { // Arrange @@ -17,31 +120,35 @@ struct ButtonTests { @Test("Lock button press") func lockButtonPress() async throws { - // Act - try await TestHelpers.runAxeCommand("button lock", simulatorUDID: defaultSimulatorUDID) - - // Note: Cannot assert UI state as lock button locks the device - // This test verifies the command executes without error + try await pressLockingButton("button lock") } @Test("Side button press") func sideButtonPress() async throws { - // Act - try await TestHelpers.runAxeCommand("button side-button", simulatorUDID: defaultSimulatorUDID) - - // Note: Side button behavior varies by device - // This test verifies the command executes without error + _ = try await runButtonCommand("button side-button") + #expect( + try await waitForSpringBoardState("hasBlankedScreen", expectedState: 1) == 1, + "A short side-button press should blank the simulator display" + ) + try await rebootSimulator() + } + + @Test("Apple Pay button command executes") + func applePayButtonPress() async throws { + try await waitForDeviceHIDServiceIfRequired() + try await TestHelpers.runAxeCommand("button apple-pay", simulatorUDID: defaultSimulatorUDID) + } + + @Test("Siri button command executes") + func siriButtonPress() async throws { + try await waitForDeviceHIDServiceIfRequired() + try await TestHelpers.runAxeCommand("button siri", simulatorUDID: defaultSimulatorUDID) } @Test("Button press with duration") func buttonPressWithDuration() async throws { - // Act - let startTime = Date() - try await TestHelpers.runAxeCommand("button lock --duration 2", simulatorUDID: defaultSimulatorUDID) - let endTime = Date() - - // Assert timing - let duration = endTime.timeIntervalSince(startTime) - #expect(duration >= 2.0, "Command should take at least 2 seconds with delays") + let duration = try await runButtonCommand("button lock --duration 2") + #expect(duration >= .seconds(2), "Command should hold the button for at least 2 seconds") + try await rebootSimulator() } } diff --git a/Tests/CLIErrorTests.swift b/Tests/CLIErrorTests.swift new file mode 100644 index 0000000..9fafc9d --- /dev/null +++ b/Tests/CLIErrorTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import AXe + +@Suite("CLI Error Tests") +struct CLIErrorTests { + @Test("String description contains only the user-facing message") + func stringDescriptionIsUserFacing() { + let error = CLIError(errorDescription: "Simulator not found.") + + #expect(String(describing: error) == "Simulator not found.") + } + + @Test("AXe runtime error types provide user-facing descriptions") + func axeRuntimeErrorsAreUserFacing() { + #expect( + String(describing: TextToHIDEvents.TextConversionError.unsupportedCharacter("💥")) + == "No keycode found for character: '💥'" + ) + #expect( + String(describing: ShellTokenizer.TokenizerError.danglingEscape) + == "Dangling escape sequence in batch step." + ) + #expect( + String(describing: VideoProcessingError.failedToDecodeImage) + == "AXe could not decode a simulator video frame." + ) + #expect( + VideoProcessingError.failedToDecodeImage.localizedDescription + == "AXe could not decode a simulator video frame." + ) + #expect( + String(describing: HIDBrokerNotReadyError()) + == "AXe could not establish simulator input. Wait for the simulator to finish booting and try again." + ) + #expect( + HIDBrokerNotReadyError().localizedDescription + == "AXe could not establish simulator input. Wait for the simulator to finish booting and try again." + ) + } + + @Test("Broker responses expose only curated errors") + func brokerResponsesAreUserFacing() { + let curatedError = CLIError(errorDescription: "A useful recovery message.") + let frameworkError = NSError( + domain: "PrivateFramework", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Internal transport implementation detail"] + ) + + #expect(HIDBroker.brokerResponseDescription(for: curatedError) == "A useful recovery message.") + #expect(HIDBroker.brokerResponseDescription(for: frameworkError) == HIDBroker.inputDeliveryFailureDescription) + #expect(!HIDBroker.brokerResponseDescription(for: frameworkError).contains("implementation detail")) + } + + @Test("Missing simulator errors use public terminology and provide recovery guidance") + func missingSimulatorErrorIsActionable() { + let error = CLIError.simulatorNotFound(udid: "EXAMPLE-UDID") + + #expect(error.description == "No simulator with UDID EXAMPLE-UDID was found. Run `axe list-simulators` to see available simulators.") + #expect(!error.description.contains("set")) + } + +} diff --git a/Tests/Goldens/README.md b/Tests/Goldens/README.md new file mode 100644 index 0000000..17cdb31 --- /dev/null +++ b/Tests/Goldens/README.md @@ -0,0 +1,26 @@ +# Compatibility Goldens + +Each child directory is one explicit Xcode/runtime matrix cell. The directory name must identify the selected Xcode and simulator runtime builds, for example `xcode-26.5-17F42_ios-26.5-23F77`. + +Regenerate a cell with the exact release-shaped payload and a booted simulator containing AxePlayground: + +```bash +scripts/regenerate-goldens.sh \ + --axe /absolute/path/to/axe \ + --developer-dir /Applications/Xcode-26.5.0.app/Contents/Developer \ + --udid SIMULATOR_UDID \ + --matrix-id xcode-26.5-17F42_ios-26.5-23F77 \ + --update +``` + +Use `--check` with the same arguments and the exact same AXe payload to regenerate into a temporary directory and compare it with the checked-in cell. The payload must be byte-identical because provenance validation includes its SHA-256. + +Each cell has three parts: + +- `contract.json` records stable matrix identity: schema version, Xcode version/build, runtime build, and fixture. +- `stable/` records argv, stdout, stderr, stdin where applicable, and exit status. It covers help and unknown-option behavior for every public subcommand, plus command-specific typed validation, stdin, and output-path contracts. Hierarchy values are intentionally excluded because labels, frames, and identifiers can be fixture- or host-specific; `stable/hierarchy/schema-types.json` records normalized JSON paths and types. +- `provenance.json` records volatile run identity: exact AXe payload SHA-256, simulator UDID/device name, and the SHA-256 of the stable contract. `--check` requires the supplied payload SHA-256 and generated stable contract SHA-256 to match this file, then compares `contract.json` and `stable/`. A different simulator does not create golden churn, but a rebuilt payload must be byte-identical. + +The checked-in cells record configurations on which AXe compatibility was validated. Their exact Xcode and runtime build identifiers are provenance, not build or release requirements. Add a new matrix directory when validating another supported configuration; do not replace an existing cell to broaden the claimed support range. + +These cells are manually captured compatibility evidence, not general-purpose PR regression fixtures. Capture or update them only from a clean, release-shaped, versioned AXe payload. The `version` case intentionally records that payload's exact reported version, so a dirty development build is invalid evidence even when the remaining command output is correct. Normal Swift tests run in CI; golden capture remains a deliberate validation step for the supported Xcode/runtime matrix. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/contract.json b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/contract.json new file mode 100644 index 0000000..b569406 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/contract.json @@ -0,0 +1,8 @@ +{ + "fixture_screen": "tap-test", + "matrix_id": "xcode-26.5-17F42_ios-26.5-23F77", + "runtime_build": "23F77", + "schema_version": 1, + "xcode_build": "17F42", + "xcode_version": "Xcode 26.5" +} diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/provenance.json b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/provenance.json new file mode 100644 index 0000000..7e1066d --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/provenance.json @@ -0,0 +1,9 @@ +{ + "axe_payload_sha256": "583bc18685e9e8f57b8ddb00366c5659f2ed8998d918b6eef2172c4139eb30fd", + "schema_version": 1, + "simulator": { + "device_name": "iPhone 17 Pro", + "udid": "3E654A95-E95D-4023-ACDA-8064CC90FBDA" + }, + "stable_contract_sha256": "0791e86c72f7fe55d50a29ad8b5f0e48f593651307b037a7a3d03a2a1bb218ec" +} diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/argv.txt new file mode 100644 index 0000000..590bdc1 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/argv.txt @@ -0,0 +1 @@ +axe batch --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/stderr.txt new file mode 100644 index 0000000..daa0782 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--udid ' +Help: --udid The UDID of the simulator. +Usage: axe batch --udid [--step ...] [--file ] [--stdin] [--ax-cache ] [--type-submission ] [--type-chunk-size ] [--tap-style ] [--continue-on-error] [--wait-timeout ] [--poll-interval ] [--verbose] + See 'axe batch --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-batch-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/argv.txt new file mode 100644 index 0000000..06550bc --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/argv.txt @@ -0,0 +1 @@ +axe button --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/stderr.txt new file mode 100644 index 0000000..4496eb0 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '' +Help: The button to press. +Usage: axe button [--duration ] --udid + See 'axe button --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-button-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/argv.txt new file mode 100644 index 0000000..70ec796 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/argv.txt @@ -0,0 +1 @@ +axe describe-ui --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/stderr.txt new file mode 100644 index 0000000..15b2c53 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--udid ' +Help: --udid The UDID of the simulator. +Usage: axe describe-ui --udid [--point ] + See 'axe describe-ui --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-describe-ui-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/argv.txt new file mode 100644 index 0000000..63fd2e1 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/argv.txt @@ -0,0 +1 @@ +axe drag --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/stderr.txt new file mode 100644 index 0000000..a22d6cc --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--start-x ' +Help: --start-x The X coordinate of the starting point. +Usage: axe drag --start-x --start-y --end-x --end-y [--duration ] [--steps ] [--pre-delay ] [--post-delay ] --udid + See 'axe drag --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-drag-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/argv.txt new file mode 100644 index 0000000..9159aeb --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/argv.txt @@ -0,0 +1 @@ +axe gesture --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/stderr.txt new file mode 100644 index 0000000..1355563 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '' +Help: The gesture preset to perform. +Usage: axe gesture [--screen-width ] [--screen-height ] [--duration ] [--delta ] [--pre-delay ] [--post-delay ] --udid + See 'axe gesture --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-gesture-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/argv.txt new file mode 100644 index 0000000..903fc11 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/argv.txt @@ -0,0 +1 @@ +axe init --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/stderr.txt new file mode 100644 index 0000000..3f21d4c --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/stderr.txt @@ -0,0 +1,3 @@ +Error: Unknown option '--axe-invalid-option' +Usage: axe init [--client ] [--dest ] [--force] [--uninstall] [--print] + See 'axe init --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-init-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/argv.txt new file mode 100644 index 0000000..bc24760 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/argv.txt @@ -0,0 +1 @@ +axe key-combo --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/stderr.txt new file mode 100644 index 0000000..c4bee4c --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--modifiers ' +Help: --modifiers Comma-separated list of modifier keycodes to hold (0-255). +Usage: axe key-combo --modifiers --key --udid + See 'axe key-combo --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-combo-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/argv.txt new file mode 100644 index 0000000..6014848 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/argv.txt @@ -0,0 +1 @@ +axe key-sequence --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/stderr.txt new file mode 100644 index 0000000..41aacdc --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--keycodes ' +Help: --keycodes Comma-separated list of HID keycodes to press in sequence. +Usage: axe key-sequence --keycodes [--delay ] --udid + See 'axe key-sequence --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-sequence-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/argv.txt new file mode 100644 index 0000000..8f09033 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/argv.txt @@ -0,0 +1 @@ +axe key --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/stderr.txt new file mode 100644 index 0000000..d8875ba --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '' +Help: The HID keycode to press (0-255). +Usage: axe key [--duration ] --udid + See 'axe key --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-key-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/argv.txt new file mode 100644 index 0000000..56be9b5 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/argv.txt @@ -0,0 +1 @@ +axe list-simulators --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/stderr.txt new file mode 100644 index 0000000..b9933cd --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/stderr.txt @@ -0,0 +1,3 @@ +Error: Unknown option '--axe-invalid-option' +Usage: axe list-simulators + See 'axe list-simulators --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-list-simulators-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/argv.txt new file mode 100644 index 0000000..2cb7359 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/argv.txt @@ -0,0 +1 @@ +axe record-video --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/stderr.txt new file mode 100644 index 0000000..41aeaec --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--udid ' +Help: --udid The UDID of the simulator. +Usage: axe record-video --udid [--fps ] [--quality ] [--scale ] [--output ] + See 'axe record-video --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-record-video-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/argv.txt new file mode 100644 index 0000000..0aaae25 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/argv.txt @@ -0,0 +1 @@ +axe screenshot --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/stderr.txt new file mode 100644 index 0000000..cd9bb7d --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--udid ' +Help: --udid The UDID of the simulator. +Usage: axe screenshot --udid [--output ] + See 'axe screenshot --help' for more information. diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/stdout.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-screenshot-unknown-option/stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/argv.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/argv.txt new file mode 100644 index 0000000..30e199d --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/argv.txt @@ -0,0 +1 @@ +axe slider --axe-invalid-option diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/exit-code.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/exit-code.txt new file mode 100644 index 0000000..900731f --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/exit-code.txt @@ -0,0 +1 @@ +64 diff --git a/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/stderr.txt b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/stderr.txt new file mode 100644 index 0000000..c987b20 --- /dev/null +++ b/Tests/Goldens/xcode-26.5-17F42_ios-26.5-23F77/stable/cases/error-slider-unknown-option/stderr.txt @@ -0,0 +1,4 @@ +Error: Missing expected argument '--value ' +Help: --value Target slider value as a percentage from 0 to 100. +Usage: axe slider [--id ] [--label