From b1c50b9a23bc4979c5741a7445d06d1b520866e3 Mon Sep 17 00:00:00 2001 From: BariBariGood Date: Tue, 11 Aug 2026 18:54:01 +0000 Subject: [PATCH 1/2] Fix misleading describe-ui error when the simulator UI is not ready yet Querying the accessibility hierarchy within a few seconds of booting a simulator or launching an app can fail with: 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 even when no point was specified and the UI simply has not finished coming up. Retrying the same command a few seconds later succeeds. Detect this transient translation-unavailable error (including when it is nested in an underlying error), retry with a short exponential backoff, and if the UI still is not ready after the retries, surface an accurate error explaining that the simulator UI is not ready for accessibility queries yet. --- CHANGELOG.md | 6 + .../AXe/Utilities/AccessibilityFetcher.swift | 47 +++++++- Tests/AccessibilityFetcherTests.swift | 105 ++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf435a..d789516 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. + ## [v1.8.0] - 2026-07-20 ### Added diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index 3f21140..44cfda1 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -48,10 +48,12 @@ struct AccessibilityFetcher { logger: logger, dependencies: recoveryDependencies ) { - if let point { - return try await fetchAccessibilityInfoJSONData(from: target, at: point) + try await retryingWhileTranslationUnavailable(logger: logger) { + 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 +122,45 @@ 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) + }, + operation: @MainActor () async throws -> T + ) async throws -> T { + precondition(maximumAttempts > 0) + for attempt in 0..( simulatorUDID: String, logger: AxeLogger, diff --git a/Tests/AccessibilityFetcherTests.swift b/Tests/AccessibilityFetcherTests.swift index 47dc327..7503d60 100644 --- a/Tests/AccessibilityFetcherTests.swift +++ b/Tests/AccessibilityFetcherTests.swift @@ -271,6 +271,111 @@ 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(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? From 215ff934220840e57f4baf60c42f31df68bf3fc4 Mon Sep 17 00:00:00 2001 From: BariBariGood Date: Thu, 27 Aug 2026 17:29:49 -0700 Subject: [PATCH 2/2] fix(describe-ui): preserve point-query failure cause when retries are exhausted Point queries can exhaust translation 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. The terminal error now keeps both causes for point queries while frontmost queries keep their existing message. Adds a test for the point-query terminal path and links the originating getsentry/XcodeBuildMCP issue in the changelog. --- CHANGELOG.md | 2 +- .../AXe/Utilities/AccessibilityFetcher.swift | 20 ++++++++++--- Tests/AccessibilityFetcherTests.swift | 30 +++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d789516..85708cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- 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 diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index 44cfda1..8f8fb0b 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -48,7 +48,12 @@ struct AccessibilityFetcher { logger: logger, dependencies: recoveryDependencies ) { - try await retryingWhileTranslationUnavailable(logger: logger) { + 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) } @@ -139,9 +144,18 @@ struct AccessibilityFetcher { 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..