diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb9b8a37..d23e2c625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 5b90f056e..eabd7c1a5 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -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 = [ + "Application", + "Window", + "Other", + "ScrollView" + ] private static let collapsedTabCandidateTypes: Set = [ .button, .link, @@ -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 { @@ -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) @@ -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,