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 fb61299b17..c251296501 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 8e7e83d99d..532cb84886 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/MaestroDriverLib/Sources/MaestroDriverLib/Helpers/SystemDialogMatcher.swift b/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Helpers/SystemDialogMatcher.swift new file mode 100644 index 0000000000..baee3eaeac --- /dev/null +++ b/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Helpers/SystemDialogMatcher.swift @@ -0,0 +1,226 @@ +import Foundation + +/// How to recognise one iOS system permission dialog and which button answers it. +public struct SystemPermissionDialog: Equatable { + /// Key in the permissions map sent by the CLI (e.g. "location"). + public let key: String + /// Case-insensitive substring of the dialog title (or, failing that, its static texts). + public let labelPattern: String + /// Number of action buttons at the bottom of the dialog. Buttons in the dialog body + /// (e.g. the location prompt's "Precise: On" map toggle, which can appear more than once) + /// come before them, so action buttons are the last `actionButtonCount` buttons. + public let actionButtonCount: Int + /// PermissionValue → index among the action buttons. No entry means "leave it alone". + public let buttonMap: [PermissionValue: Int] + /// Optional exact (case-insensitive) labels tried before the index, per value. + public let preferredLabels: [PermissionValue: [String]] + + public init( + key: String, + labelPattern: String, + actionButtonCount: Int = 2, + buttonMap: [PermissionValue: Int], + preferredLabels: [PermissionValue: [String]] = [:] + ) { + self.key = key + self.labelPattern = labelPattern + self.actionButtonCount = actionButtonCount + self.buttonMap = buttonMap + self.preferredLabels = preferredLabels + } +} + +/// What the runner should tap, decided purely from an `AXElement` snapshot. +public struct SystemDialogTap: Equatable { + public enum Reason: Equatable { + case accountAlert + case permission(key: String, value: PermissionValue) + case customPermission(key: String, value: PermissionValue) + } + + public let reason: Reason + public let buttonLabel: String + public let frame: AXFrame +} + +/// Pure logic for finding and answering SpringBoard system dialogs (permission prompts and +/// Apple Account password alerts) in an `AXElement` tree taken with one throwing `snapshot()`. +/// +/// This replaces per-element XCUIElement queries (`.exists`, `.label`, +/// `allElementsBoundByIndex`, `element(boundBy:)`). Those resolve every element again, +/// and when one cannot be resolved — e.g. the remote map view in the location prompt — they +/// record an XCTest failure instead of throwing, which ends `testHttpServer` and the runner. +public enum SystemDialogMatcher { + + /// All known iOS system permission dialogs. + public static let permissionDialogs: [SystemPermissionDialog] = [ + // [Don't Allow (0), Allow (1)] + SystemPermissionDialog(key: "notifications", labelPattern: "Would Like to Send You Notifications", + buttonMap: [.allow: 1, .deny: 0]), + // [Allow Once (0), Allow While Using App (1), Don't Allow (2)], preceded by the + // "Precise: On" map button(s) in the body. + SystemPermissionDialog(key: "location", labelPattern: "to use your location", + actionButtonCount: 3, + buttonMap: [.allow: 1, .always: 1, .inuse: 1, .deny: 2, .never: 2], + preferredLabels: [ + .allow: ["allow while using app"], + .always: ["allow while using app"], + .inuse: ["allow while using app"], + ]), + // "Would Like to Use Bluetooth" or "to find Bluetooth devices" + SystemPermissionDialog(key: "bluetooth", labelPattern: "Bluetooth", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "camera", labelPattern: "Would Like to Access the Camera", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "microphone", labelPattern: "Would Like to Access the Microphone", buttonMap: [.allow: 1, .deny: 0]), + // [Don't Allow (0), Allow Limited Access (1), Allow Full Access (2)] + SystemPermissionDialog(key: "photos", labelPattern: "Would Like to Access Your Photos", + actionButtonCount: 3, + buttonMap: [.allow: 2, .limited: 1, .deny: 0]), + SystemPermissionDialog(key: "contacts", labelPattern: "Would Like to Access Your Contacts", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "calendar", labelPattern: "Would Like to Access Your Calendar", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "reminders", labelPattern: "Would Like to Access Your Reminders", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "speech", labelPattern: "Would Like to Access Speech Recognition", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "motion", labelPattern: "Would Like to Access Your Motion", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "homekit", labelPattern: "Would Like to Access Your Home Data", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "medialibrary", labelPattern: "Would Like to Access Apple Music", buttonMap: [.allow: 1, .deny: 0]), + SystemPermissionDialog(key: "siri", labelPattern: "Would Like to Access Siri", buttonMap: [.allow: 1, .deny: 0]), + // [Ask App Not to Track (0), Allow (1)] + SystemPermissionDialog(key: "usertracking", labelPattern: "Would Like Permission to Track", buttonMap: [.allow: 1, .deny: 0]), + // "Allow X to find devices on local networks?" + SystemPermissionDialog(key: "localnetwork", labelPattern: "local network", buttonMap: [.allow: 1, .deny: 0]), + ] + + /// Permissions answered with "Allow" unless configured otherwise: these dialogs block the + /// whole UI and are rarely denied on purpose. + public static let autoAllowPermissions = ["bluetooth", "localnetwork"] + + // MARK: - Tree helpers + + /// Alerts and sheets in the tree, outermost first, in depth-first pre-order. + public static func dialogs(in root: AXElement) -> [AXElement] { + var result = [AXElement]() + func visit(_ element: AXElement) { + if element.elementType == ElementType.alert || element.elementType == ElementType.sheet { + result.append(element) + return + } + element.children?.forEach(visit) + } + visit(root) + return result + } + + /// The dialog's own label followed by its static text labels (non-empty, in order). + public static func texts(of dialog: AXElement) -> [String] { + ([dialog.label] + dialog.descendants(ofType: ElementType.staticText).map(\.label)) + .filter { !$0.isEmpty } + } + + /// Buttons in the dialog, in depth-first pre-order (the order XCUIElement queries use). + public static func buttons(of dialog: AXElement) -> [AXElement] { + dialog.descendants(ofType: ElementType.button) + } + + static func normalize(_ string: String) -> String { + string + .replacingOccurrences(of: "\u{2019}", with: "'") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + // MARK: - Apple Account alerts + + /// The "Not Now"/"Cancel" button of an Apple Account password alert anywhere in `root`, + /// or nil when there is no such alert or it has no safe dismiss button. + public static func accountAlertDismissal(in root: AXElement) -> SystemDialogTap? { + for dialog in dialogs(in: root) where dialog.elementType == ElementType.alert { + guard AccountAlertMatcher.isAccountAlert(texts: texts(of: dialog)) else { continue } + let buttons = buttons(of: dialog) + guard let index = AccountAlertMatcher.dismissButtonIndex(buttonLabels: buttons.map(\.label)) else { + return nil + } + return SystemDialogTap(reason: .accountAlert, buttonLabel: buttons[index].label, frame: buttons[index].frame) + } + return nil + } + + // MARK: - Permission dialogs + + /// The permission dialog that matches `dialog`, by title first and static texts second. + /// Titles are tried first so a purpose string that mentions e.g. "Bluetooth" cannot + /// steer a location prompt to the bluetooth answer. + public static func matchingPermissionDialog(for dialog: AXElement) -> SystemPermissionDialog? { + let title = dialog.label.lowercased() + if let match = permissionDialogs.first(where: { title.contains($0.labelPattern.lowercased()) }) { + return match + } + let allText = texts(of: dialog).joined(separator: " ").lowercased() + return permissionDialogs.first { allText.contains($0.labelPattern.lowercased()) } + } + + /// The button to tap for `value` in a dialog already known to be `definition`. + public static func button(for value: PermissionValue, definition: SystemPermissionDialog, in dialog: AXElement) -> AXElement? { + let buttons = buttons(of: dialog) + guard !buttons.isEmpty else { return nil } + + if let wanted = definition.preferredLabels[value]?.map(normalize), + let byLabel = buttons.first(where: { wanted.contains(normalize($0.label)) }) { + return byLabel + } + + guard let index = definition.buttonMap[value] else { return nil } + let actionButtons = buttons.count >= definition.actionButtonCount + ? Array(buttons.suffix(definition.actionButtonCount)) + : buttons + return actionButtons.indices.contains(index) ? actionButtons[index] : nil + } + + /// The button to tap to answer a permission dialog in `root` according to `permissions`, + /// or nil when nothing should be tapped. + public static func permissionTap(in root: AXElement, permissions: [String: PermissionValue]) -> SystemDialogTap? { + let dialogs = dialogs(in: root) + for dialog in dialogs { + guard let definition = matchingPermissionDialog(for: dialog), + let value = permissions[definition.key], + value != .unset, value != .unknown, + let button = button(for: value, definition: definition, in: dialog) else { + continue + } + return SystemDialogTap(reason: .permission(key: definition.key, value: value), + buttonLabel: button.label, frame: button.frame) + } + // An alert/sheet we chose not to answer (e.g. location unset) must not be answered + // through the custom path because its purpose string mentions "Bluetooth". + guard dialogs.isEmpty else { return nil } + return customPermissionTap(in: root, permissions: permissions) + } + + /// Bluetooth / Local Network prompts that are neither alerts nor sheets: a static text + /// mentioning the pattern plus an "Allow"/"Don't Allow" button somewhere in the tree. + /// Only the auto-allow permissions are handled this way, and only when the tree has no + /// alert or sheet. + static func customPermissionTap(in root: AXElement, permissions: [String: PermissionValue]) -> SystemDialogTap? { + let staticTexts = root.descendants(ofType: ElementType.staticText).map { $0.label.lowercased() } + guard !staticTexts.isEmpty else { return nil } + let buttons = root.descendants(ofType: ElementType.button) + + for key in autoAllowPermissions { + guard let definition = permissionDialogs.first(where: { $0.key == key }), + let value = permissions[key], + value != .unset, value != .unknown else { + continue + } + let pattern = definition.labelPattern.lowercased() + guard staticTexts.contains(where: { $0.contains(pattern) }) else { continue } + + let target: String + switch value { + case .deny, .never: target = "don't allow" + default: target = "allow" + } + guard let button = buttons.first(where: { normalize($0.label) == target }) else { continue } + return SystemDialogTap(reason: .customPermission(key: key, value: value), + buttonLabel: button.label, frame: button.frame) + } + return nil + } +} diff --git a/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/ElementType.swift b/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/ElementType.swift index 97d4b9cf07..9e71a7f1a3 100644 --- a/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/ElementType.swift +++ b/maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/ElementType.swift @@ -2,6 +2,12 @@ import Foundation /// Element type constants matching XCUIElement.ElementType raw values public enum ElementType { + /// XCUIElement.ElementType.sheet.rawValue + public static let sheet: Int = 5 + /// XCUIElement.ElementType.alert.rawValue + public static let alert: Int = 7 /// XCUIElement.ElementType.button.rawValue public static let button: Int = 9 -} \ No newline at end of file + /// XCUIElement.ElementType.staticText.rawValue + public static let staticText: Int = 48 +} diff --git a/maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/SystemDialogMatcherTests.swift b/maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/SystemDialogMatcherTests.swift new file mode 100644 index 0000000000..1172d2f5b2 --- /dev/null +++ b/maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/SystemDialogMatcherTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import MaestroDriverLib + +final class SystemDialogMatcherTests: XCTestCase { + + // MARK: - Fixtures + + private func frame(_ y: Double) -> AXFrame { + ["X": 20, "Y": y, "Width": 300, "Height": 44] + } + + private func button(_ label: String, y: Double, children: [AXElement]? = nil) -> AXElement { + AXElement(frame: frame(y), label: label, elementType: ElementType.button, children: children) + } + + private func text(_ label: String) -> AXElement { + AXElement(label: label, elementType: ElementType.staticText) + } + + /// The map / "Precise: On" part of the iOS location prompt. It is a remote view hosted by + /// CoreLocationVanillaWhenInUseAuthPromptPlugin and shows up twice in SpringBoard's snapshot. + private func mapSubtree(y: Double) -> [AXElement] { + [ + AXElement(label: "Map", elementType: 43), + button("Precise: On", y: y, children: [text("Precise: On")]), + ] + } + + /// SpringBoard tree rebuilt from the TestingBot session f3ffd402 hierarchy JSON. + private func locationPrompt(title: String = "Allow “FR8BOT” to use your location?") -> AXElement { + let body = AXElement(elementType: 46, children: [ + AXElement(label: "Location@3x.png", elementType: 43), + text(title), + text("We use your location to track deliveries and Bluetooth beacons."), + ] + mapSubtree(y: 300) + mapSubtree(y: 300)) + let actions = AXElement(elementType: 46, children: [ + button("Allow Once", y: 500), + button("Allow While Using App", y: 550), + button("Don’t Allow", y: 600), + ]) + let alert = AXElement(label: title, elementType: ElementType.alert, children: [body, actions]) + let window = AXElement(elementType: 4, children: [alert]) + return AXElement(label: " ", elementType: 2, children: [window]) + } + + private func twoButtonAlert(title: String, buttons labels: [String]) -> AXElement { + let alert = AXElement(label: title, elementType: ElementType.alert, children: [ + text(title), + ] + labels.enumerated().map { button($0.element, y: 500 + Double($0.offset) * 50) }) + return AXElement(elementType: 2, children: [AXElement(elementType: 4, children: [alert])]) + } + + // MARK: - Location prompt with map + + func testLocationPromptAllowTapsAllowWhileUsingApp() { + let tap = SystemDialogMatcher.permissionTap(in: locationPrompt(), permissions: ["location": .allow]) + XCTAssertEqual(tap?.buttonLabel, "Allow While Using App") + XCTAssertEqual(tap?.frame, frame(550)) + XCTAssertEqual(tap?.reason, .permission(key: "location", value: .allow)) + } + + func testLocationPromptDenyTapsDontAllowDespiteMapButtons() { + let tap = SystemDialogMatcher.permissionTap(in: locationPrompt(), permissions: ["location": .deny]) + XCTAssertEqual(tap?.buttonLabel, "Don’t Allow") + } + + func testLocationPromptIndexFallbackSkipsDuplicatedPreciseButtons() { + // No preferred label for .inuse would still land on index 1 of the last 3 buttons. + let definition = SystemPermissionDialog(key: "location", labelPattern: "to use your location", + actionButtonCount: 3, buttonMap: [.inuse: 1]) + let alert = SystemDialogMatcher.dialogs(in: locationPrompt()).first! + XCTAssertEqual(SystemDialogMatcher.buttons(of: alert).count, 5) + XCTAssertEqual(SystemDialogMatcher.button(for: .inuse, definition: definition, in: alert)?.label, + "Allow While Using App") + } + + func testLocationPromptUnsetIsLeftAlone() { + XCTAssertNil(SystemDialogMatcher.permissionTap(in: locationPrompt(), permissions: ["location": .unset])) + XCTAssertNil(SystemDialogMatcher.permissionTap(in: locationPrompt(), permissions: [:])) + } + + func testPurposeStringMentioningBluetoothDoesNotSteerLocationPrompt() { + // Location unset but bluetooth auto-allowed: the purpose string says "Bluetooth", but the + // title is a location prompt, so nothing must be tapped. + let tap = SystemDialogMatcher.permissionTap(in: locationPrompt(), permissions: ["bluetooth": .allow]) + XCTAssertNil(tap) + } + + func testLocationPromptIsNotAnAccountAlert() { + XCTAssertNil(SystemDialogMatcher.accountAlertDismissal(in: locationPrompt())) + } + + // MARK: - Other dialogs + + func testNotificationsAllowAndDeny() { + let tree = twoButtonAlert(title: "“App” Would Like to Send You Notifications", + buttons: ["Don’t Allow", "Allow"]) + XCTAssertEqual(SystemDialogMatcher.permissionTap(in: tree, permissions: ["notifications": .allow])?.buttonLabel, "Allow") + XCTAssertEqual(SystemDialogMatcher.permissionTap(in: tree, permissions: ["notifications": .deny])?.buttonLabel, "Don’t Allow") + } + + func testPhotosLimited() { + let tree = twoButtonAlert(title: "“App” Would Like to Access Your Photos", + buttons: ["Don’t Allow", "Allow Limited Access", "Allow Full Access"]) + XCTAssertEqual(SystemDialogMatcher.permissionTap(in: tree, permissions: ["photos": .limited])?.buttonLabel, "Allow Limited Access") + XCTAssertEqual(SystemDialogMatcher.permissionTap(in: tree, permissions: ["photos": .allow])?.buttonLabel, "Allow Full Access") + } + + func testCustomLocalNetworkDialogWithoutAlert() { + let tree = AXElement(elementType: 2, children: [ + text("“App” would like to find and connect to devices on your local network."), + button("Don’t Allow", y: 500), + button("Allow", y: 550), + ]) + let tap = SystemDialogMatcher.permissionTap(in: tree, permissions: ["localnetwork": .allow]) + XCTAssertEqual(tap?.buttonLabel, "Allow") + XCTAssertEqual(tap?.reason, .customPermission(key: "localnetwork", value: .allow)) + } + + func testNoDialogNoTap() { + let tree = AXElement(elementType: 2, children: [text("Home"), button("Settings", y: 10)]) + XCTAssertNil(SystemDialogMatcher.permissionTap(in: tree, permissions: ["location": .allow, "bluetooth": .allow])) + XCTAssertNil(SystemDialogMatcher.accountAlertDismissal(in: tree)) + } + + // MARK: - Apple Account alert + + func testAccountAlertDismissedWithNotNow() { + let tree = twoButtonAlert(title: "Apple Account Verification", buttons: ["Settings", "Not Now"]) + let tap = SystemDialogMatcher.accountAlertDismissal(in: tree) + XCTAssertEqual(tap?.buttonLabel, "Not Now") + XCTAssertEqual(tap?.frame, frame(550)) + XCTAssertEqual(tap?.reason, .accountAlert) + } + + func testAccountAlertWithoutDismissButtonIsLeftAlone() { + let tree = twoButtonAlert(title: "Apple Account Verification", buttons: ["Settings"]) + XCTAssertNil(SystemDialogMatcher.accountAlertDismissal(in: tree)) + } +} diff --git a/maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.pbxproj b/maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.pbxproj index b0a642cd01..a5687145d4 100644 --- a/maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.pbxproj +++ b/maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.pbxproj @@ -101,12 +101,14 @@ BBBB00042DF24A0000000004 /* PermissionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00142DF24A0000000014 /* PermissionValue.swift */; }; BBBB00052DF24A0000000005 /* PermissionButtonFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */; }; CCCC0001AA00000000000001 /* AccountAlertMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC0003AA00000000000003 /* AccountAlertMatcher.swift */; }; + CCCC0005AA00000000000005 /* SystemDialogMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC0007AA00000000000007 /* SystemDialogMatcher.swift */; }; BBBB00062DF24A0000000006 /* MaestroDriverLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00162DF24A0000000016 /* MaestroDriverLib.swift */; }; BBBB00072DF24A0000000007 /* MaestroDriverLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; }; BBBB00082DF24A0000000008 /* MaestroDriverLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; }; BBBB00092DF24A0000000009 /* MaestroDriverLib.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BBBB000A2DF24A000000000A /* PermissionButtonFinderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */; }; CCCC0002AA00000000000002 /* AccountAlertMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC0004AA00000000000004 /* AccountAlertMatcherTests.swift */; }; + CCCC0006AA00000000000006 /* SystemDialogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC0008AA00000000000008 /* SystemDialogMatcherTests.swift */; }; BBBB000B2DF24A000000000B /* AXElementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00182DF24A0000000018 /* AXElementTests.swift */; }; BBBB000C2DF24A000000000C /* AXFrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00192DF24A0000000019 /* AXFrameTests.swift */; }; C2DB03C0028C821C6F580B2C /* XCAXClient_iOS+FBSnapshotReqParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 526111792DE5E8E5007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m */; }; @@ -352,9 +354,11 @@ BBBB00142DF24A0000000014 /* PermissionValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionValue.swift; sourceTree = ""; }; BBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionButtonFinder.swift; sourceTree = ""; }; CCCC0003AA00000000000003 /* AccountAlertMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlertMatcher.swift; sourceTree = ""; }; + CCCC0007AA00000000000007 /* SystemDialogMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemDialogMatcher.swift; sourceTree = ""; }; BBBB00162DF24A0000000016 /* MaestroDriverLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroDriverLib.swift; sourceTree = ""; }; BBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionButtonFinderTests.swift; sourceTree = ""; }; CCCC0004AA00000000000004 /* AccountAlertMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlertMatcherTests.swift; sourceTree = ""; }; + CCCC0008AA00000000000008 /* SystemDialogMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemDialogMatcherTests.swift; sourceTree = ""; }; BBBB00182DF24A0000000018 /* AXElementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXElementTests.swift; sourceTree = ""; }; BBBB00192DF24A0000000019 /* AXFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXFrameTests.swift; sourceTree = ""; }; BBBB001A2DF24A000000001A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -770,6 +774,7 @@ children = ( BBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */, CCCC0003AA00000000000003 /* AccountAlertMatcher.swift */, + CCCC0007AA00000000000007 /* SystemDialogMatcher.swift */, ); path = Helpers; sourceTree = ""; @@ -779,6 +784,7 @@ children = ( BBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */, CCCC0004AA00000000000004 /* AccountAlertMatcherTests.swift */, + CCCC0008AA00000000000008 /* SystemDialogMatcherTests.swift */, BBBB00182DF24A0000000018 /* AXElementTests.swift */, BBBB00192DF24A0000000019 /* AXFrameTests.swift */, ); @@ -1076,6 +1082,7 @@ BBBB00042DF24A0000000004 /* PermissionValue.swift in Sources */, BBBB00052DF24A0000000005 /* PermissionButtonFinder.swift in Sources */, CCCC0001AA00000000000001 /* AccountAlertMatcher.swift in Sources */, + CCCC0005AA00000000000005 /* SystemDialogMatcher.swift in Sources */, BBBB00062DF24A0000000006 /* MaestroDriverLib.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1086,6 +1093,7 @@ files = ( BBBB000A2DF24A000000000A /* PermissionButtonFinderTests.swift in Sources */, CCCC0002AA00000000000002 /* AccountAlertMatcherTests.swift in Sources */, + CCCC0006AA00000000000006 /* SystemDialogMatcherTests.swift in Sources */, BBBB000B2DF24A000000000B /* AXElementTests.swift in Sources */, BBBB000C2DF24A000000000C /* AXFrameTests.swift in Sources */, ); 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 2c31015569..dfbb1b1e13 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 @@ -49,10 +49,21 @@ struct ViewHierarchyHandler: HTTPHandler { func getAppViewHierarchy(foregroundApp: XCUIApplication, excludeKeyboardElements: Bool) async throws -> AXElement { let appHierarchy = try getHierarchyWithFallback(foregroundApp) - await SystemPermissionHelper.handleSystemPermissionAlertIfNeeded(appHierarchy: appHierarchy, foregroundApp: foregroundApp) - + + // One throwing SpringBoard snapshot, shared by the system dialog check and the status + // bars. When SpringBoard is in front, the app hierarchy already is that snapshot. + let springboardHierarchy: AXElement? = foregroundApp.bundleID == "com.apple.springboard" + ? appHierarchy + : logger.measure(message: "Fetch SpringBoard snapshot") { springboardSnapshot() } + + await SystemPermissionHelper.handleSystemPermissionAlertIfNeeded( + springboardHierarchy: springboardHierarchy, + appHierarchy: appHierarchy, + foregroundApp: foregroundApp + ) + let statusBars = logger.measure(message: "Fetch status bar hierarchy") { - fullStatusBars(springboardApplication) + fullStatusBars(in: springboardHierarchy) } ?? [] // Fetch Safari WebView hierarchy for iOS 26+ (runs in separate SafariViewService process) @@ -242,24 +253,28 @@ struct ViewHierarchyHandler: HTTPHandler { return try? elementHierarchy(xcuiElement: alert) } - /// Status bars are read from one throwing snapshot of `element` (SpringBoard). + /// One throwing snapshot of SpringBoard, or nil when it fails. /// - /// Do not use `statusBars.firstMatch.exists` / `statusBars.allElementsBoundByIndex` here. - /// On iOS 26 one of SpringBoard's status bars is hosted by the keyboard process - /// (com.apple.InputUI). When the keyboard goes away mid-query, resolving that element - /// fails with kAXErrorInvalidUIElement, and those non-throwing APIs record an XCTest - /// failure ("Failed to resolve query") instead of throwing. That fails testHttpServer - /// and the runner exits. `snapshot()` throws instead, so a race just drops the status bars. - func fullStatusBars(_ element: XCUIApplication) -> [AXElement]? { + /// Do not replace this with `statusBars.firstMatch.exists`, `alerts.firstMatch.exists`, + /// `allElementsBoundByIndex`, `.label` etc. Those non-throwing APIs resolve elements again, + /// and when one cannot be resolved (a status bar hosted by com.apple.InputUI while the + /// keyboard goes away, or the remote map view in the location prompt) they record an + /// XCTest failure instead of throwing. That fails testHttpServer and the runner exits. + func springboardSnapshot() -> AXElement? { do { - let root = try elementHierarchy(xcuiElement: element) - let statusBars = root.descendants(ofType: Int(XCUIElement.ElementType.statusBar.rawValue)) - return statusBars.isEmpty ? nil : statusBars + return try elementHierarchy(xcuiElement: springboardApplication) } catch { - NSLog("[ViewHierarchyHandler] Status bar snapshot failed, continuing without status bars: \(error.localizedDescription)") + NSLog("[ViewHierarchyHandler] SpringBoard snapshot failed, continuing without it: \(error.localizedDescription)") return nil } } + + /// Status bars collected from the SpringBoard snapshot (see `springboardSnapshot()`). + func fullStatusBars(in springboardHierarchy: AXElement?) -> [AXElement]? { + guard let root = springboardHierarchy else { return nil } + let statusBars = root.descendants(ofType: Int(XCUIElement.ElementType.statusBar.rawValue)) + return statusBars.isEmpty ? nil : statusBars + } /// Fetches the Safari WebView hierarchy for iOS 26+ where SFSafariViewController /// runs in a separate process (com.apple.SafariViewService). diff --git a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/SystemPermissionHelper.swift b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/SystemPermissionHelper.swift index 19016944dd..ebe3026a08 100644 --- a/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/SystemPermissionHelper.swift +++ b/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/SystemPermissionHelper.swift @@ -1,130 +1,21 @@ import XCTest import MaestroDriverLib +/// Answers iOS system permission dialogs and dismisses Apple Account password alerts. +/// +/// Everything is read from one throwing `snapshot()` (an `AXElement` tree) and matched by +/// `SystemDialogMatcher` in MaestroDriverLib. Do not go back to non-throwing XCUIElement +/// APIs (`.exists`, `.label`, `allElementsBoundByIndex`, `element(boundBy:)`) here: they +/// resolve each element again, and when one cannot be resolved they record an XCTest failure +/// instead of throwing. That fails `testHttpServer` and the runner exits. The iOS 26 +/// location prompt reliably triggers this: its map / "Precise: On" part is a remote view +/// hosted by CoreLocationVanillaWhenInUseAuthPromptPlugin, and per-element queries into it +/// killed the runner on a physical iPhone. @MainActor final class SystemPermissionHelper { - // MARK: - Permission dialog definitions - - /// Defines how to detect and handle a specific iOS system permission dialog. - struct PermissionDialog { - let key: String // Matches the key in the permissions map (e.g. "location") - let labelPattern: String // Case-insensitive substring to match in the alert/sheet label - /// Maps a PermissionValue to the button index to tap. If the value is not in the map, no action is taken. - let buttonMap: [PermissionValue: Int] - } - - /// All known iOS system permission dialogs. - /// Button indices are 0-based and correspond to the order buttons appear in the dialog. - static let permissionDialogs: [PermissionDialog] = [ - // Notifications: [Don't Allow (0), Allow (1)] - PermissionDialog( - key: "notifications", - labelPattern: "Would Like to Send You Notifications", - buttonMap: [.allow: 1, .deny: 0] - ), - // Location: [Allow Once (0), Allow While Using App (1), Don't Allow (2)] - PermissionDialog( - key: "location", - labelPattern: "to use your location", - buttonMap: [.allow: 1, .always: 1, .inuse: 1, .deny: 2, .never: 2] - ), - // Bluetooth: [Don't Allow (0), Allow (1)] - // Dialog text varies: "Would Like to Use Bluetooth" or "to find Bluetooth devices" - PermissionDialog( - key: "bluetooth", - labelPattern: "Bluetooth", - buttonMap: [.allow: 1, .deny: 0] - ), - // Camera: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "camera", - labelPattern: "Would Like to Access the Camera", - buttonMap: [.allow: 1, .deny: 0] - ), - // Microphone: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "microphone", - labelPattern: "Would Like to Access the Microphone", - buttonMap: [.allow: 1, .deny: 0] - ), - // Photos: [Don't Allow (0), Allow Limited Access (1), Allow Full Access (2)] - PermissionDialog( - key: "photos", - labelPattern: "Would Like to Access Your Photos", - buttonMap: [.allow: 2, .limited: 1, .deny: 0] - ), - // Contacts: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "contacts", - labelPattern: "Would Like to Access Your Contacts", - buttonMap: [.allow: 1, .deny: 0] - ), - // Calendar: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "calendar", - labelPattern: "Would Like to Access Your Calendar", - buttonMap: [.allow: 1, .deny: 0] - ), - // Reminders: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "reminders", - labelPattern: "Would Like to Access Your Reminders", - buttonMap: [.allow: 1, .deny: 0] - ), - // Speech Recognition: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "speech", - labelPattern: "Would Like to Access Speech Recognition", - buttonMap: [.allow: 1, .deny: 0] - ), - // Motion & Fitness: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "motion", - labelPattern: "Would Like to Access Your Motion", - buttonMap: [.allow: 1, .deny: 0] - ), - // HomeKit: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "homekit", - labelPattern: "Would Like to Access Your Home Data", - buttonMap: [.allow: 1, .deny: 0] - ), - // Media Library: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "medialibrary", - labelPattern: "Would Like to Access Apple Music", - buttonMap: [.allow: 1, .deny: 0] - ), - // Siri: [Don't Allow (0), OK (1)] - PermissionDialog( - key: "siri", - labelPattern: "Would Like to Access Siri", - buttonMap: [.allow: 1, .deny: 0] - ), - // User Tracking (App Tracking Transparency): [Ask App Not to Track (0), Allow (1)] - PermissionDialog( - key: "usertracking", - labelPattern: "Would Like Permission to Track", - buttonMap: [.allow: 1, .deny: 0] - ), - // Local Network: [Don't Allow (0), Allow (1)] - // Dialog: "Allow X to find devices on local networks?" - PermissionDialog( - key: "localnetwork", - labelPattern: "local network", - buttonMap: [.allow: 1, .deny: 0] - ), - ] - - // MARK: - Helpers - - /// Permissions that are auto-allowed by default unless explicitly configured otherwise. - /// These dialogs block the entire UI and are rarely intentionally denied in test flows. - private static let autoAllowPermissions = ["bluetooth", "localnetwork"] - - /// AXElement-based button finder from upstream MaestroDriverLib. Used as an additional - /// fallback when XCUIElement queries don't find a matching dialog. + /// AXElement-based notifications button finder from upstream MaestroDriverLib, kept as a + /// last resort for a notifications prompt that is not exposed as an alert or sheet. private static let buttonFinder = PermissionButtonFinder() /// Returns the stored permissions with auto-allow defaults applied. @@ -133,279 +24,150 @@ final class SystemPermissionHelper { var permissions = try? JSONDecoder().decode([String: PermissionValue].self, from: data) else { // Even with no permissions configured, still handle auto-allow permissions var defaults: [String: PermissionValue] = [:] - for key in autoAllowPermissions { defaults[key] = .allow } + for key in SystemDialogMatcher.autoAllowPermissions { defaults[key] = .allow } return defaults } - // Apply auto-allow defaults for permissions not explicitly set - for key in autoAllowPermissions { - if permissions[key] == nil { - permissions[key] = .allow - } + for key in SystemDialogMatcher.autoAllowPermissions where permissions[key] == nil { + permissions[key] = .allow } return permissions.isEmpty ? nil : permissions } - // MARK: - Interruption monitor handler - - /// Handles a system interruption element (alert or sheet) delivered by addUIInterruptionMonitor. - /// Returns true if the dialog was handled, false otherwise. - static func handleInterruption(_ element: XCUIElement) -> Bool { - // Apple Account password prompts are dismissed regardless of the configured permissions. - if dismissAccountAlertIfMatching(element, source: "Interruption") { - return true - } - - guard let permissions = effectivePermissions() else { - NSLog("[SystemPermissionHelper] Interruption received but no permissions configured") - return false - } - - // Collect all text from the interrupting element for matching - let elementLabel = element.label - NSLog("[SystemPermissionHelper] Interruption received — label: '\(elementLabel)'") - - // Log all static texts for debugging - let texts = element.staticTexts.allElementsBoundByIndex - let textLabels = texts.prefix(10).map { $0.label } - NSLog("[SystemPermissionHelper] Interruption static texts: \(textLabels)") - - // Log all buttons for debugging - let buttons = element.buttons.allElementsBoundByIndex - let buttonLabels = buttons.enumerated().map { "[\($0.offset)]: '\($0.element.label)'" } - NSLog("[SystemPermissionHelper] Interruption buttons: \(buttonLabels)") - - // Build a combined string from all static texts for more reliable matching - let allText = textLabels.joined(separator: " ") - - for dialog in permissionDialogs { - guard let permissionValue = permissions[dialog.key] else { - continue - } - if permissionValue == .unset || permissionValue == .unknown { - continue - } - - // Check if the dialog label or any static text contains the pattern - let pattern = dialog.labelPattern.lowercased() - let matches = elementLabel.lowercased().contains(pattern) || allText.lowercased().contains(pattern) - - if matches { - NSLog("[SystemPermissionHelper] Matched '\(dialog.key)' permission dialog (pattern: '\(dialog.labelPattern)')") - - guard let buttonIndex = dialog.buttonMap[permissionValue] else { - NSLog("[SystemPermissionHelper] No button mapping for '\(dialog.key)' with value '\(permissionValue.rawValue)', skipping") - continue - } - - let button = element.buttons.element(boundBy: buttonIndex) - if button.exists { - let buttonLabel = button.label - NSLog("[SystemPermissionHelper] Tapping button at index \(buttonIndex) ('\(buttonLabel)') for '\(dialog.key)' permission with value '\(permissionValue.rawValue)'") - button.tap() - NSLog("[SystemPermissionHelper] Successfully tapped '\(buttonLabel)' on '\(dialog.key)' permission dialog") - return true - } else { - NSLog("[SystemPermissionHelper] Button at index \(buttonIndex) not found for '\(dialog.key)'. Available buttons (\(buttons.count)): \(buttonLabels.joined(separator: ", "))") - } - } + private static func describe(_ tap: SystemDialogTap) -> String { + switch tap.reason { + case .accountAlert: + return "Apple Account alert" + case .permission(let key, let value): + return "'\(key)' permission dialog (\(value.rawValue))" + case .customPermission(let key, let value): + return "'\(key)' custom permission dialog (\(value.rawValue))" + @unknown default: + return "system dialog" } - - NSLog("[SystemPermissionHelper] No matching permission dialog found in interruption") - return false } - // MARK: - ViewHierarchy hook (called from ViewHierarchyHandler as additional safety net) + /// Last dialog summary logged by the view-hierarchy hook, so a dialog that stays on screen + /// is logged once rather than on every request. + private static var lastLoggedDialogs = "" - /// Called from ViewHierarchyHandler when Springboard is the foreground app. - /// Acts as a fallback in case the interruption monitor did not fire. - /// - /// Queries the alert/sheet ONCE and matches its label against all patterns — avoids - /// running N predicate queries per element type, which causes XCUITest errors to accumulate - /// and crash the test with "Interrupted by waiter." - /// - /// The `appHierarchy` parameter (added in upstream v2.6.0 to enable AXElement-based - /// button finding via MaestroDriverLib's `PermissionButtonFinder`) is accepted to keep the - /// caller signature stable. The XCUIElement-based path below covers all 16 known dialogs - /// and is preferred; we fall through to PermissionButtonFinder only for notifications when - /// the XCUIElement query path didn't match. - static func handleSystemPermissionAlertIfNeeded(appHierarchy: AXElement, foregroundApp: XCUIApplication) async { - // Apple Account password prompts are shown by SpringBoard on top of whatever app is in - // the foreground, so check SpringBoard itself rather than the foreground app. - let springboardAlert = springboard.alerts.firstMatch - if springboardAlert.exists, dismissAccountAlertIfMatching(springboardAlert, source: "Fallback") { - return + private static func logDialogs(in root: AXElement, source: String, onlyOnChange: Bool) { + let summary = SystemDialogMatcher.dialogs(in: root).map { dialog -> String in + let kind = dialog.elementType == ElementType.alert ? "alert" : "sheet" + let title = SystemDialogMatcher.texts(of: dialog).first ?? "" + let buttons = SystemDialogMatcher.buttons(of: dialog).map(\.label) + return "\(kind) '\(title)' buttons: \(buttons)" + }.joined(separator: "; ") + if onlyOnChange { + guard summary != lastLoggedDialogs else { return } + lastLoggedDialogs = summary } - - guard foregroundApp.bundleID == "com.apple.springboard" else { - return - } - - guard let permissions = effectivePermissions() else { - return - } - - // Check the first alert once, then match its label against all dialog patterns - let alert = foregroundApp.alerts.element - if alert.exists { - let alertLabel = alert.label.lowercased() - NSLog("[SystemPermissionHelper] [Fallback] Found alert with label: '\(alert.label)'") - - for dialog in permissionDialogs { - guard let permissionValue = permissions[dialog.key], - permissionValue != .unset && permissionValue != .unknown else { - continue - } - if alertLabel.contains(dialog.labelPattern.lowercased()) { - NSLog("[SystemPermissionHelper] [Fallback] Matched '\(dialog.key)' in alert") - tapButton(in: alert, dialog: dialog, permissionValue: permissionValue) - return - } - } - NSLog("[SystemPermissionHelper] [Fallback] Alert did not match any known permission dialog") - } - - // Check the first sheet once - let sheet = foregroundApp.sheets.element - if sheet.exists { - let sheetLabel = sheet.label.lowercased() - NSLog("[SystemPermissionHelper] [Fallback] Found sheet with label: '\(sheet.label)'") - - for dialog in permissionDialogs { - guard let permissionValue = permissions[dialog.key], - permissionValue != .unset && permissionValue != .unknown else { - continue - } - if sheetLabel.contains(dialog.labelPattern.lowercased()) { - NSLog("[SystemPermissionHelper] [Fallback] Matched '\(dialog.key)' in sheet") - tapButton(in: sheet, dialog: dialog, permissionValue: permissionValue) - return - } - } - NSLog("[SystemPermissionHelper] [Fallback] Sheet did not match any known permission dialog") + if !summary.isEmpty { + NSLog("[SystemPermissionHelper] [\(source)] Found \(summary)") } + } - // For custom system dialogs (e.g. Bluetooth "find devices", Local Network) - // that are neither alerts nor sheets: check static texts with targeted predicates. - // Only check auto-allow permissions here to minimize queries. - for key in autoAllowPermissions { - guard let dialog = permissionDialogs.first(where: { $0.key == key }), - let permissionValue = permissions[key], - permissionValue != .unset && permissionValue != .unknown else { - continue - } - let predicate = NSPredicate(format: "label CONTAINS[c] %@", dialog.labelPattern) - let matchingText = foregroundApp.staticTexts.matching(predicate).element - if matchingText.exists { - NSLog("[SystemPermissionHelper] [Fallback] Detected '\(dialog.key)' via Springboard static text (custom dialog)") - let targetLabel: String - switch permissionValue { - case .deny, .never: - targetLabel = "Don\u{2019}t Allow" - default: - targetLabel = "Allow" - } - let button = foregroundApp.buttons[targetLabel] - if button.exists { - NSLog("[SystemPermissionHelper] [Fallback] Tapping '\(targetLabel)' for '\(dialog.key)'") - button.tap() - NSLog("[SystemPermissionHelper] [Fallback] Successfully tapped '\(targetLabel)' on '\(dialog.key)'") - } else { - NSLog("[SystemPermissionHelper] [Fallback] Button '\(targetLabel)' not found on Springboard for '\(dialog.key)'") - } - return - } + /// The tap to perform for `root`, if any: an Apple Account alert first (regardless of + /// permissions), then a permission dialog when `permissionsApply` is true. + private static func decide(in root: AXElement, permissionsApply: Bool) -> SystemDialogTap? { + if let tap = SystemDialogMatcher.accountAlertDismissal(in: root) { + return tap } - - // Last-resort: upstream's AXElement-based button finder for the notifications dialog. - // Only fires for notifications because that's all PermissionButtonFinder currently knows - // about. The XCUIElement path above will normally cover this; this is purely defensive. - if let notificationsPermission = permissions["notifications"] { - let result = buttonFinder.findButtonToTap(for: notificationsPermission, in: appHierarchy) - switch result { - case .found(let frame): - NSLog("[SystemPermissionHelper] [AX-Fallback] Found notifications button at frame: \(frame)") - await tapAtCenter(of: frame, in: foregroundApp) - case .noButtonsFound: - NSLog("[SystemPermissionHelper] [AX-Fallback] No buttons found in hierarchy") - case .noActionRequired: - break - @unknown default: - NSLog("[SystemPermissionHelper] [AX-Fallback] Unknown permission button result: \(result)") - } + guard permissionsApply, let permissions = effectivePermissions() else { + return nil } + return SystemDialogMatcher.permissionTap(in: root, permissions: permissions) } - // MARK: - Apple Account password prompts - - private static let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + // MARK: - Interruption monitor handler - /// Dismisses an "Apple Account Verification" / "Sign In to iTunes Store"-style alert by tapping - /// "Not Now" (or "Cancel"). These appear when an Apple Account is signed in on the device and - /// block the app under test; they are never permission prompts. Never taps "Settings" or - /// "Sign In". Returns true only when a dismiss button was tapped. - static func dismissAccountAlertIfMatching(_ element: XCUIElement, source: String) -> Bool { - let texts = [element.label] + element.staticTexts.allElementsBoundByIndex.prefix(10).map { $0.label } - guard AccountAlertMatcher.isAccountAlert(texts: texts) else { + /// Handles a system interruption element (alert or sheet) delivered by addUIInterruptionMonitor. + /// Returns true if the dialog was handled, false otherwise. + static func handleInterruption(_ element: XCUIElement) -> Bool { + let root: AXElement + do { + root = AXElement(try element.snapshot().dictionaryRepresentation) + } catch { + NSLog("[SystemPermissionHelper] [Interruption] snapshot failed: \(error.localizedDescription)") return false } + logDialogs(in: root, source: "Interruption", onlyOnChange: false) - let buttons = element.buttons.allElementsBoundByIndex - let buttonLabels = buttons.map { $0.label } - NSLog("[SystemPermissionHelper] [\(source)] Apple Account alert detected: \(texts) buttons: \(buttonLabels)") - - guard let index = AccountAlertMatcher.dismissButtonIndex(buttonLabels: buttonLabels) else { - NSLog("[SystemPermissionHelper] [\(source)] Apple Account alert has no dismiss button, leaving it") + guard let tap = decide(in: root, permissionsApply: true) else { + NSLog("[SystemPermissionHelper] [Interruption] No matching dialog, leaving it") return false } - buttons[index].tap() - NSLog("[SystemPermissionHelper] [\(source)] Dismissed Apple Account alert with '\(buttonLabels[index])'") + // The interruption handler is synchronous, so tap through XCUIElement. Resolve the + // button by its (unique) label rather than by index, and only after the throwing + // snapshot above has confirmed it is there. + NSLog("[SystemPermissionHelper] [Interruption] Tapping '\(tap.buttonLabel)' on \(describe(tap))") + element.buttons[tap.buttonLabel].firstMatch.tap() return true } - /// Tap a button by index within a dialog element (alert or sheet). - private static func tapButton(in dialogElement: XCUIElement, dialog: PermissionDialog, permissionValue: PermissionValue) { - guard let buttonIndex = dialog.buttonMap[permissionValue] else { - NSLog("[SystemPermissionHelper] [Fallback] No button mapping for '\(dialog.key)' with value '\(permissionValue.rawValue)', skipping") + // MARK: - ViewHierarchy hook + + /// Called from ViewHierarchyHandler on every view-hierarchy request, as a safety net in case + /// the interruption monitor did not fire. + /// + /// - Parameters: + /// - springboardHierarchy: one throwing snapshot of SpringBoard taken by the caller, or nil + /// when that snapshot failed (then nothing is done). + /// - appHierarchy: the foreground app's hierarchy, used by the notifications last resort. + /// - foregroundApp: permission dialogs are only answered while SpringBoard is in front. + /// Apple Account alerts are dismissed whatever app is in front. + static func handleSystemPermissionAlertIfNeeded( + springboardHierarchy: AXElement?, + appHierarchy: AXElement, + foregroundApp: XCUIApplication + ) async { + guard let springboardHierarchy = springboardHierarchy else { + NSLog("[SystemPermissionHelper] [Fallback] No SpringBoard snapshot, skipping system dialog check") return } - let button = dialogElement.buttons.element(boundBy: buttonIndex) - if button.exists { - let buttonLabel = button.label - NSLog("[SystemPermissionHelper] [Fallback] Tapping '\(buttonLabel)' for '\(dialog.key)'") - button.tap() - NSLog("[SystemPermissionHelper] [Fallback] Successfully tapped '\(buttonLabel)' on '\(dialog.key)'") - } else { - NSLog("[SystemPermissionHelper] [Fallback] Button at index \(buttonIndex) not found for '\(dialog.key)'") + let springboardInFront = foregroundApp.bundleID == "com.apple.springboard" + if springboardInFront { + logDialogs(in: springboardHierarchy, source: "Fallback", onlyOnChange: true) } - } - /// Tap at the center of an element's frame using the RunnerDaemonProxy. - /// Imported from upstream v2.6.0 — used by the AXElement fallback path. - private static func tapAtCenter(of frame: AXFrame, in app: XCUIApplication) async { - let x = frame.centerX - let y = frame.centerY + if let tap = decide(in: springboardHierarchy, permissionsApply: springboardInFront) { + NSLog("[SystemPermissionHelper] [Fallback] Tapping '\(tap.buttonLabel)' on \(describe(tap)) at \(tap.frame)") + await tapAtCenter(of: tap.frame) + return + } - NSLog("Tapping at coordinates: (\(x), \(y))") + guard springboardInFront, let notifications = effectivePermissions()?["notifications"] else { + return + } + switch buttonFinder.findButtonToTap(for: notifications, in: appHierarchy) { + case .found(let frame): + NSLog("[SystemPermissionHelper] [AX-Fallback] Found notifications button at frame: \(frame)") + await tapAtCenter(of: frame) + case .noButtonsFound, .noActionRequired: + break + @unknown default: + break + } + } + /// Tap at the center of a frame with RunnerDaemonProxy. Needs no XCUIElement resolution, + /// so it cannot record an XCTest failure. + private static func tapAtCenter(of frame: AXFrame) async { let (width, height) = ScreenSizeHelper.physicalScreenSize() let point = ScreenSizeHelper.orientationAwarePoint( width: width, height: height, - point: CGPoint(x: CGFloat(x), y: CGFloat(y)) + point: CGPoint(x: CGFloat(frame.centerX), y: CGFloat(frame.centerY)) ) let eventRecord = EventRecord(orientation: .portrait) - _ = eventRecord.addPointerTouchEvent( - at: point, - touchUpAfter: nil - ) + _ = eventRecord.addPointerTouchEvent(at: point, touchUpAfter: nil) do { try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord) } catch { - NSLog("Error tapping permission button: \(error)") + NSLog("[SystemPermissionHelper] Error tapping system dialog button: \(error)") } } }