Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- iOS: `snapshot` now recovers from a sparse accessibility tree. When the public `XCUIElement.snapshot()` traversal collapses to only structural containers (application/window/other) while the screen is rendering content — common on React Native apps — the runner falls back to a query-based (`XCUIElementQuery`) flat traversal so `testID`s and on-screen controls remain visible instead of returning a 2–3 node tree. The recovered payload is marked `truncated` and carries an explanatory `message`.

## 0.15.0

- Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ extension RunnerTests {
private static let rawSnapshotMaxNodes = 5_000
private static let rawSnapshotTooLargeHint =
"Raw iOS snapshot exceeded the runner payload guard. Use regular snapshot for visible UI, or scope/depth-limit raw snapshot when inspecting a large accessibility tree."
private static let degenerateTreeRecoveryMessage =
"Recovered the snapshot through accessibility element queries: XCTest's snapshot tree was sparse (only structural containers) while the screen has rendered content. This is common on React Native apps, where XCUIElementQuery still resolves controls the public snapshot omits. Nodes below are a flattened, query-resolved view of the on-screen controls."
private static let structuralOnlyNodeTypes: Set<String> = [
"Application",
"Window",
"Other",
"ScrollView"
]
private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
.button,
.link,
Expand Down Expand Up @@ -243,10 +251,12 @@ extension RunnerTests {

}

return DataPayload(
nodes: applyHiddenContentHints(hiddenContentHintsByNodeIndex, to: nodes),
truncated: false
)
let resolvedNodes = applyHiddenContentHints(hiddenContentHintsByNodeIndex, to: nodes)
if snapshotNodesAreDegenerate(resolvedNodes),
let recovered = snapshotQueryRecovery(app: app, options: options) {
return recovered
}
return DataPayload(nodes: resolvedNodes, truncated: false)
}

func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
Expand Down Expand Up @@ -380,6 +390,41 @@ extension RunnerTests {
return DataPayload(nodes: nodes, truncated: truncated)
}

private func snapshotNodesAreDegenerate(_ nodes: [SnapshotNode]) -> Bool {
guard !nodes.isEmpty else { return false }
for node in nodes {
let hasContent = (node.label?.isEmpty == false)
|| (node.identifier?.isEmpty == false)
|| (node.value?.isEmpty == false)
if hasContent || node.hittable { return false }
if !Self.structuralOnlyNodeTypes.contains(node.type) { return false }
}
return true
}

private func snapshotQueryRecovery(
app: XCUIApplication,
options: SnapshotOptions
) -> DataPayload? {
let recovery = snapshotFlatInteractive(
app: app,
options: SnapshotOptions(
interactiveOnly: false,
compact: options.compact,
depth: options.depth ?? Int.max,
scope: options.scope,
raw: false
)
)
guard let nodes = recovery.nodes, nodes.count > 1 else { return nil }
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_DEGENERATE_TREE_RECOVERED=%d", nodes.count)
return DataPayload(
message: Self.degenerateTreeRecoveryMessage,
nodes: nodes,
truncated: true
)
}

private func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
Expand Down Expand Up @@ -554,6 +599,73 @@ extension RunnerTests {
XCTAssertEqual(currentBundleId, "com.example.app")
}

private func makeTestSnapshotNode(
index: Int,
type: String,
label: String? = nil,
identifier: String? = nil,
value: String? = nil,
hittable: Bool = false
) -> SnapshotNode {
SnapshotNode(
index: index,
type: type,
label: label,
identifier: identifier,
value: value,
rect: SnapshotRect(x: 0, y: 0, width: 402, height: 874),
enabled: true,
focused: nil,
selected: nil,
hittable: hittable,
depth: 0,
parentIndex: nil,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
}

func testSnapshotNodesAreDegenerateDetectsStructuralOnlyTree() {
let nodes = [
makeTestSnapshotNode(index: 0, type: "Application"),
makeTestSnapshotNode(index: 1, type: "Window"),
makeTestSnapshotNode(index: 2, type: "Other")
]

XCTAssertTrue(snapshotNodesAreDegenerate(nodes))
}

func testSnapshotNodesAreNotDegenerateWhenContentPresent() {
let nodes = [
makeTestSnapshotNode(index: 0, type: "Application"),
makeTestSnapshotNode(index: 1, type: "StaticText", label: "Welcome back!")
]

XCTAssertFalse(snapshotNodesAreDegenerate(nodes))
}

func testSnapshotNodesAreNotDegenerateWhenHittableControlPresent() {
let nodes = [
makeTestSnapshotNode(index: 0, type: "Application"),
makeTestSnapshotNode(index: 1, type: "Other", hittable: true)
]

XCTAssertFalse(snapshotNodesAreDegenerate(nodes))
}

func testSnapshotNodesAreNotDegenerateWhenTypedControlPresent() {
let nodes = [
makeTestSnapshotNode(index: 0, type: "Application"),
makeTestSnapshotNode(index: 1, type: "Button")
]

XCTAssertFalse(snapshotNodesAreDegenerate(nodes))
}

func testSnapshotNodesAreNotDegenerateWhenEmpty() {
XCTAssertFalse(snapshotNodesAreDegenerate([]))
}

private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode {
SnapshotNode(
index: 0,
Expand Down