Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 56 additions & 3 deletions Sources/AXe/Utilities/AccessibilityFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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<T>(
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..<maximumAttempts {
do {
return try await operation()
} catch {
guard isTranslationUnavailableError(error) else {
throw error
}
guard attempt < maximumAttempts - 1 else {
throw CLIError(errorDescription: terminalErrorDescription)
}
logger.info().log("Simulator UI is not ready for accessibility queries yet; retrying")
try await wait(.milliseconds(500 * (1 << attempt)))
}
}
throw CLIError(errorDescription: "The simulator UI is not ready for accessibility queries yet.")
}

static func retryingAfterTestManagerRecovery<T>(
simulatorUDID: String,
logger: AxeLogger,
Expand Down
135 changes: 135 additions & 0 deletions Tests/AccessibilityFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down