diff --git a/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-ios.zip b/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-ios.zip index 28b2d8b645..bb76b9a5c9 100644 Binary files a/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-ios.zip and b/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-ios.zip differ diff --git a/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.zip b/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.zip index db2ce799ce..d22c5b2b91 100644 Binary files a/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.zip and b/maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.zip differ diff --git a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift index 56665483dd..9224359fab 100644 --- a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift +++ b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift @@ -8,6 +8,9 @@ struct ViewHierarchyHandler: HTTPHandler { private let springboardApplication = XCUIApplication(bundleIdentifier: "com.apple.springboard") private let snapshotMaxDepth = 60 + /// Upper bound for each accessibility request made while reading the iOS 26+ + /// SafariViewService hierarchy (see getSafariWebViewHierarchy). + private let safariSnapshotTimeout: TimeInterval = 10 private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -261,32 +264,78 @@ struct ViewHierarchyHandler: HTTPHandler { } let safariWebService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService") - + let isRunning = safariWebService.state == .runningForeground || safariWebService.state == .runningBackground guard isRunning else { return nil } - - let webViewCount = safariWebService.webViews.count - guard webViewCount > 0 else { - return nil + + // This snapshot runs on the main actor and XCTest cannot cancel it, so a heavy page + // stalls every hierarchy-based command. Cap each accessibility request instead and, + // when it runs out, return the app hierarchy without the Safari part. + // + // Only throwing XCTest APIs may run under the cap. A query such as + // `webViews.count` that hits the timeout records an XCTest failure instead of + // throwing, which fails testHttpServer and ends the runner (verified on an iOS 26.4 + // simulator with kAXErrorIPCTimeout). One snapshot both detects the web view and + // provides the hierarchy. + let startedAt = Date() + defer { + NSLog("[ViewHierarchyHandler] Safari WebView hierarchy step took \(String(format: "%.2f", Date().timeIntervalSince(startedAt)))s") } - - NSLog("[Start] Fetching Safari WebView hierarchy (\(webViewCount) webview(s) detected)") - - do { - AXClientSwizzler.overwriteDefaultParameters["maxDepth"] = snapshotMaxDepth - let safariHierarchy = try elementHierarchy(xcuiElement: safariWebService) - NSLog("[Done] Safari WebView hierarchy fetched successfully") - if let jsonData = try? JSONEncoder().encode(safariHierarchy), - let jsonString = String(data: jsonData, encoding: .utf8) { - logLargeString("[ViewHierarchyHandler] Safari WebView hierarchy JSON", jsonString) + return withAXTimeout(safariSnapshotTimeout) { + do { + AXClientSwizzler.overwriteDefaultParameters["maxDepth"] = snapshotMaxDepth + let snapshot = try safariWebService.snapshot() + guard containsWebView(snapshot) else { + return nil + } + NSLog("[Start] Fetching Safari WebView hierarchy (webview detected)") + let safariHierarchy = AXElement(snapshot.dictionaryRepresentation) + NSLog("[Done] Safari WebView hierarchy fetched successfully") + if let jsonData = try? JSONEncoder().encode(safariHierarchy), + let jsonString = String(data: jsonData, encoding: .utf8) { + logLargeString("[ViewHierarchyHandler] Safari WebView hierarchy JSON", jsonString) + } + return safariHierarchy + } catch { + NSLog("[Error] Failed to fetch Safari WebView hierarchy within \(safariSnapshotTimeout)s, returning app hierarchy without it: \(error.localizedDescription)") + return nil } - return safariHierarchy + } + } + + private func containsWebView(_ snapshot: XCUIElementSnapshot) -> Bool { + if snapshot.elementType == .webView { + return true + } + return snapshot.children.contains { containsWebView($0) } + } + + /// Runs `body` with XCTest's per-request accessibility timeout lowered to `timeout`, + /// restoring the previous value afterwards. If the timeout cannot be changed, `body` + /// still runs, just without the cap. + private func withAXTimeout(_ timeout: TimeInterval, _ body: () -> T) -> T { + guard let client = AXClientProxy.sharedClient() else { + return body() + } + let previous = client.axTimeout() + do { + try client.setAXTimeout(timeout) } catch { - NSLog("[Error] Failed to fetch Safari WebView hierarchy: \(error.localizedDescription)") - return nil + NSLog("[ViewHierarchyHandler] Could not set AX timeout to \(timeout)s, continuing without it: \(error.localizedDescription)") + return body() + } + defer { + if previous > 0 { + do { + try client.setAXTimeout(previous) + } catch { + NSLog("[ViewHierarchyHandler] Could not restore AX timeout to \(previous)s: \(error.localizedDescription)") + } + } } + return body() } private func findRecoveryElement(_ element: XCUIElement) throws -> XCUIElement { diff --git a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.h b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.h index a3948deeae..f1b5434c51 100644 --- a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.h +++ b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.h @@ -9,4 +9,11 @@ - (NSDictionary *)defaultParameters; +/// Current timeout (seconds) XCTest applies to each accessibility request, such as a snapshot. +- (NSTimeInterval)axTimeout; + +/// Sets the timeout XCTest applies to each accessibility request. A snapshot that runs +/// past it fails with an error instead of blocking the runner's main thread. +- (BOOL)setAXTimeout:(NSTimeInterval)timeout error:(NSError **)error; + @end diff --git a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.m b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.m index 13df36d2c5..88e89d59c2 100644 --- a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.m +++ b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.m @@ -1,6 +1,7 @@ #import "AXClientProxy.h" #import "XCAccessibilityElement.h" #import "XCUIDevice.h" +#import "XCAXClient_iOS.h" static id AXClient = nil; @@ -26,4 +27,25 @@ - (NSDictionary *)defaultParameters { return [AXClient defaultParameters]; } +- (NSTimeInterval)axTimeout +{ + if (![AXClient respondsToSelector:@selector(AXTimeout)]) { + return 0; + } + return [(XCAXClient_iOS *)AXClient AXTimeout]; +} + +- (BOOL)setAXTimeout:(NSTimeInterval)timeout error:(NSError **)error +{ + if (![AXClient respondsToSelector:@selector(_setAXTimeout:error:)]) { + if (error) { + *error = [NSError errorWithDomain:@"maestro.AXClientProxy" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"XCAXClient_iOS does not support _setAXTimeout:error:"}]; + } + return NO; + } + return [(XCAXClient_iOS *)AXClient _setAXTimeout:timeout error:error]; +} + @end