Skip to content
Merged
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
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ public struct AXElement: Codable, Equatable {
return max ?? 1
}

/// Every element in this subtree (including `self`) whose `elementType` matches,
/// in depth-first pre-order. Nested matches are included, like
/// `XCUIElementQuery.allElementsBoundByIndex` on a descendants query.
public func descendants(ofType elementType: Int) -> [AXElement] {
var matches = [AXElement]()

func collect(_ element: AXElement) {
if element.elementType == elementType {
matches.append(element)
}
element.children?.forEach(collect)
}

collect(self)
return matches
}

public func filterAllChildrenNotInKeyboardBounds(_ keyboardFrame: CGRect) -> [AXElement] {
var filteredChildren = [AXElement]()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,30 @@ final class AXElementTests: XCTestCase {

XCTAssertNotEqual(element1, element2)
}
}

// MARK: - descendants(ofType:)

func testDescendantsOfType_returnsMatchesInPreOrderIncludingNested() {
let statusBar = 25
let nested = AXElement(identifier: "nested", elementType: statusBar)
let outer = AXElement(identifier: "outer", elementType: statusBar, children: [nested])
let other = AXElement(identifier: "second", elementType: statusBar)
let root = AXElement(
identifier: "springboard",
elementType: 2,
children: [
AXElement(identifier: "window", elementType: 4, children: [outer]),
AXElement(identifier: "button", elementType: 9),
other
]
)

XCTAssertEqual(root.descendants(ofType: statusBar).map(\.identifier), ["outer", "nested", "second"])
}

func testDescendantsOfType_includesSelfAndReturnsEmptyWhenNoMatch() {
let leaf = AXElement(identifier: "bar", elementType: 25)
XCTAssertEqual(leaf.descendants(ofType: 25).map(\.identifier), ["bar"])
XCTAssertTrue(AXElement(children: [leaf]).descendants(ofType: 9).isEmpty)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,23 @@ struct ViewHierarchyHandler: HTTPHandler {
return try? elementHierarchy(xcuiElement: alert)
}

/// Status bars are read from one throwing snapshot of `element` (SpringBoard).
///
/// 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]? {
guard element.statusBars.firstMatch.exists else {
do {
let root = try elementHierarchy(xcuiElement: element)
let statusBars = root.descendants(ofType: Int(XCUIElement.ElementType.statusBar.rawValue))
return statusBars.isEmpty ? nil : statusBars
} catch {
NSLog("[ViewHierarchyHandler] Status bar snapshot failed, continuing without status bars: \(error.localizedDescription)")
return nil
}

let snapshots = try? element.statusBars.allElementsBoundByIndex.compactMap{ (statusBar) in
try elementHierarchy(xcuiElement: statusBar)
}

return snapshots
}

/// Fetches the Safari WebView hierarchy for iOS 26+ where SFSafariViewController
Expand Down