diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf435a..85708cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the AXe iOS testing framework will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fixed `describe-ui` failing with a misleading "No translation object returned" error when the simulator UI is queried right after boot or app launch; AXe now retries with a short backoff and reports an accurate "UI is not ready yet" error if the UI never becomes ready ([#458](https://github.com/getsentry/XcodeBuildMCP/issues/458)). + ## [v1.8.0] - 2026-07-20 ### Added diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index 3f21140..8f8fb0b 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -48,10 +48,17 @@ struct AccessibilityFetcher { logger: logger, dependencies: recoveryDependencies ) { - if let point { - return try await fetchAccessibilityInfoJSONData(from: target, at: point) + try await retryingWhileTranslationUnavailable( + logger: logger, + causeHint: point == nil + ? nil + : "If you requested a specific point, it may be invalid or hidden by a fullscreen dialog." + ) { + if let point { + return try await fetchAccessibilityInfoJSONData(from: target, at: point) + } + return try await fetchFrontmostAccessibilityInfoJSONData(from: target) } - return try await fetchFrontmostAccessibilityInfoJSONData(from: target) } } @@ -120,6 +127,52 @@ struct AccessibilityFetcher { return latestData } + static func isTranslationUnavailableError(_ error: Error) -> Bool { + errorChain(from: error).contains { error in + [ + error.localizedDescription, + error.localizedFailureReason, + error.userInfo[NSDebugDescriptionErrorKey] as? String, + ].compactMap { $0?.lowercased() } + .contains { $0.contains("no translation object returned") } + } + } + + static func retryingWhileTranslationUnavailable( + logger: AxeLogger, + maximumAttempts: Int = 5, + wait: @MainActor (Duration) async throws -> Void = { duration in + try await Task.sleep(for: duration) + }, + causeHint: String? = nil, + operation: @MainActor () async throws -> T + ) async throws -> T { + precondition(maximumAttempts > 0) + // Point queries can exhaust retries on a permanent failure: the simulator + // reports an invalid or dialog-hidden point with the same translation + // error it uses for a UI that is not ready yet. Preserve both causes in + // the terminal error so callers are not misled into waiting on a bad point. + let terminalErrorDescription = [ + "The simulator UI is not ready for accessibility queries yet. This commonly happens for a few seconds after booting the simulator or launching an app. AXe retried \(maximumAttempts) times without the UI becoming ready; wait a moment and try again.", + causeHint, + ].compactMap { $0 }.joined(separator: " ") + for attempt in 0..( simulatorUDID: String, logger: AxeLogger, diff --git a/Tests/AccessibilityFetcherTests.swift b/Tests/AccessibilityFetcherTests.swift index 47dc327..f4b2b35 100644 --- a/Tests/AccessibilityFetcherTests.swift +++ b/Tests/AccessibilityFetcherTests.swift @@ -271,6 +271,141 @@ struct AccessibilityFetcherTests { } } + @Test("Classifies translation-unavailable errors including nested underlying errors") + func classifiesTranslationUnavailableErrors() { + let direct = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No translation object returned for simulator. This means you have likely specified a point onscreen that is invalid or invisible due to a fullscreen dialog"] + ) + let nested = NSError( + domain: "Wrapper", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: "Fetch failed", + NSUnderlyingErrorKey: direct, + ] + ) + let unrelated = NSError( + domain: "Accessibility", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] + ) + + #expect(AccessibilityFetcher.isTranslationUnavailableError(direct)) + #expect(AccessibilityFetcher.isTranslationUnavailableError(nested)) + #expect(!AccessibilityFetcher.isTranslationUnavailableError(unrelated)) + } + + @Test("Retries translation-unavailable failures with backoff until the UI is ready") + func retriesTranslationUnavailableFailures() async throws { + let notReady = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No translation object returned for simulator."] + ) + var attempts = 0 + var waits: [Duration] = [] + + let result = try await AccessibilityFetcher.retryingWhileTranslationUnavailable( + logger: AxeLogger(), + wait: { waits.append($0) } + ) { + attempts += 1 + if attempts < 3 { + throw notReady + } + return "ready" + } + + #expect(result == "ready") + #expect(attempts == 3) + #expect(waits == [.milliseconds(500), .milliseconds(1000)]) + } + + @Test("Reports an accurate not-ready error when translation retries are exhausted") + func reportsNotReadyErrorAfterExhaustedRetries() async { + let notReady = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No translation object returned for simulator."] + ) + var attempts = 0 + + do { + _ = try await AccessibilityFetcher.retryingWhileTranslationUnavailable( + logger: AxeLogger(), + wait: { _ in } + ) { + attempts += 1 + throw notReady + } as String + Issue.record("Expected exhausted retries to fail") + } catch { + #expect(String(reflecting: type(of: error)) == "AXe.CLIError") + let message = String(describing: error) + #expect(message.contains("not ready for accessibility queries")) + #expect(!message.lowercased().contains("translation object")) + #expect(!message.contains("fullscreen dialog")) + } + + #expect(attempts == 5) + } + + @Test("Preserves the invalid-point cause when point-query translation retries are exhausted") + func reportsPointQueryCauseAfterExhaustedRetries() async { + let invalidPoint = NSError( + domain: "Accessibility", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No translation object returned for simulator. This means you have likely specified a point onscreen that is invalid or invisible due to a fullscreen dialog"] + ) + var attempts = 0 + + do { + _ = try await AccessibilityFetcher.retryingWhileTranslationUnavailable( + logger: AxeLogger(), + wait: { _ in }, + causeHint: "If you requested a specific point, it may be invalid or hidden by a fullscreen dialog." + ) { + attempts += 1 + throw invalidPoint + } as String + Issue.record("Expected exhausted retries to fail") + } catch { + #expect(String(reflecting: type(of: error)) == "AXe.CLIError") + let message = String(describing: error) + #expect(message.contains("not ready for accessibility queries")) + #expect(message.contains("invalid or hidden by a fullscreen dialog")) + } + + #expect(attempts == 5) + } + + @Test("Propagates unrelated errors without translation retries") + func propagatesUnrelatedErrorsWithoutTranslationRetries() async { + let unrelated = NSError( + domain: "Accessibility", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] + ) + var attempts = 0 + + do { + _ = try await AccessibilityFetcher.retryingWhileTranslationUnavailable( + logger: AxeLogger(), + wait: { _ in Issue.record("Unrelated errors should not wait") } + ) { + attempts += 1 + throw unrelated + } as String + Issue.record("Expected the unrelated error to propagate") + } catch { + #expect(error.localizedDescription == "Channel disconnected") + } + + #expect(attempts == 1) + } + @Test("Restarts the canonical testmanagerd service with direct simctl arguments") func restartsCanonicalTestManagerService() async throws { var executableURL: URL?