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
2 changes: 2 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotPresentationOwnsScopeAndRelativeDepth \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPresentationRefusesAnAcquisitionCapturedForTheOtherProjection \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCaptureHintIsTheOnlyAcquisitionViewOfARequest \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRegularDepthFrontierSurvivesStructuralWrapperCollapse \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRegularDepthFrontierKeepsVisibleIndependentChildPastClippedParent \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRegularFoldClipsScrollOverflowReparentsAndBooksHints \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRegularFoldKeepsWindowCarriersButNeverHittableOutsideClip \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRegularFoldDropsSubPixelContentlessDecorationOnEveryBackend \
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- iOS regular `snapshot --depth` now measures depth after structural accessibility wrappers
collapse. The recursive-tree backend follows a bounded presented-depth frontier, so controls
that fit the requested regular depth are no longer lost behind raw wrappers; raw `--depth`
remains a traversal-depth limit. Flat query recovery is limited to one presented level, and
private AX does not claim deeper regular-depth completeness until it has a hierarchy-aware
frontier (#1797).
- iOS regular snapshot nodes now publish presentation-owned effective geometry through the existing
`rect` field: backend-reported frames remain available to acquisition, while regular output uses
the viewport and declared scroll-clip intersection. Raw snapshots and direct element reads retain
Expand Down
8 changes: 6 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,12 @@ The policy input controlling how one snapshot acquisition becomes a public proje

**Capture hint**:
The acquisition-facing view of a snapshot request, derived once from presentation options. It names
the projection a backend must serve and may narrow acquisition only where that backend can prove the
narrowing complete.
the projection a backend must serve, keeps raw traversal depth separate from regular presented depth,
and may narrow acquisition only where that backend can prove the narrowing complete.

**Regular presented-depth frontier**:
The acquisition boundary for an unscoped regular snapshot, measured against regular presented depth
after structural wrappers collapse. It is distinct from raw traversal depth.

**Snapshot eligibility**:
Membership in a presented snapshot projection, independent of whether a node is currently hittable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ extension RunnerTests {
deadline: Date = .distantFuture
) -> SnapshotAcquisition? {
#if os(iOS) && targetEnvironment(simulator)
let requestedDepth = hint.depth ?? 64
let requestedDepth = hint.rawTraversalDepth ?? 64
// An explicit --depth request is honored as asked: no accepted-depth
// memory, no frontier extension past it.
let exactDepthRequested = hint.depth != nil
let exactDepthRequested = hint.rawTraversalDepth != nil
let rememberedDepth =
exactDepthRequested
? nil
Expand Down Expand Up @@ -645,7 +645,8 @@ extension RunnerTests {
let nodes = privateAXAcquisition(
rawRoot: tree,
hint: CaptureHint(
projection: .regular, depth: nil, interactiveOnly: false, customActions: false),
projection: .regular, depth: nil, regularPresentedDepth: nil,
interactiveOnly: false, customActions: false),
viewport: CGRect(x: 0, y: 0, width: 390, height: 844)
)

Expand Down Expand Up @@ -756,7 +757,8 @@ extension RunnerTests {
]
let viewport = CGRect(x: 0, y: 0, width: 390, height: 844)
let hint = CaptureHint(
projection: .regular, depth: nil, interactiveOnly: true, customActions: false)
projection: .regular, depth: nil, regularPresentedDepth: nil,
interactiveOnly: true, customActions: false)
let acquired = privateAXAcquisition(rawRoot: tree, hint: hint, viewport: viewport)
// Acquisition serializes the drawer too; the shared fold is what hides it (#1797).
XCTAssertTrue(acquired.compactMap(\.label).contains("Admin settings"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension RunnerTests {
private func appendPrivateAXNode(_ raw: [String: Any], to nodes: inout [RawAXNode],
hint: CaptureHint, viewport: CGRect, depth: Int, parentIndex: Int?)
{
if let limit = hint.depth, depth > limit { return }
if let limit = hint.rawTraversalDepth, depth > limit { return }
let fields = privateAXFields(raw)
let index = nodes.count
nodes.append(
Expand Down Expand Up @@ -134,7 +134,8 @@ extension RunnerTests {
interactiveOnly: Bool = false
) throws -> [PresentedNode] {
let hint = CaptureHint(
projection: .regular, depth: nil, interactiveOnly: interactiveOnly, customActions: false)
projection: .regular, depth: nil, regularPresentedDepth: nil,
interactiveOnly: interactiveOnly, customActions: false)
let acquired = privateAXAcquisition(rawRoot: rawRoot, hint: hint, viewport: viewport)
return try SnapshotPresentation.presentRegular(
SnapshotAcquisition(
Expand Down Expand Up @@ -164,7 +165,9 @@ extension RunnerTests {
let regular = try privateAXRegularPresentation(rawRoot: root, viewport: viewport,
interactiveOnly: true)
let raw = privateAXAcquisition(rawRoot: root,
hint: CaptureHint(projection: .raw, depth: nil, interactiveOnly: false, customActions: false),
hint: CaptureHint(
projection: .raw, depth: nil, regularPresentedDepth: nil,
interactiveOnly: false, customActions: false),
viewport: viewport)

XCTAssertEqual(raw.map(\.type), ["Application", "ScrollView", "Button", "Image", "Button"])
Expand All @@ -190,7 +193,9 @@ extension RunnerTests {
/// can prove complete.
func testPrivateAXRawProjectionAppliesRequestedTraversalDepth() {
let raw = privateAXAcquisition(rawRoot: Self.privateAXScrolledFixture,
hint: CaptureHint(projection: .raw, depth: 2, interactiveOnly: false, customActions: false),
hint: CaptureHint(
projection: .raw, depth: 2, regularPresentedDepth: nil,
interactiveOnly: false, customActions: false),
viewport: CGRect(x: 0, y: 0, width: 402, height: 874))
XCTAssertEqual(raw.map(\.type), ["Application", "ScrollView", "Button", "Button"])
XCTAssertEqual(raw.map(\.depth), [0, 1, 2, 2])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ extension RunnerTests {
let queryRoot: XCUIElement
let rootSnapshot: XCUIElementSnapshot
let viewport: CGRect
let maxDepth: Int
}

private struct SnapshotEvaluation {
Expand All @@ -30,6 +29,8 @@ extension RunnerTests {
let snapshot: XCUIElementSnapshot
let depth: Int
let parentIndex: Int?
let parentPresentedDepth: Int
let parentTraversal: SnapshotVisibilityFold.TraversalState
}

struct SnapshotCaptureFailure: Error {
Expand Down Expand Up @@ -146,9 +147,9 @@ extension RunnerTests {

// Acquisition serializes facts: every traversed node is emitted at raw traversal depth, and
// the regular projection's clip fold runs once inside `SnapshotPresentation` (#1797). The two
// walks this backend keeps are budget and augmentation, never membership: the traversal-depth
// cut (declared residue -- regular presentation emits collapsed depth) and the collapsed-tab
// expansion, which needs live element handles.
// walks this backend keeps are the raw budget or regular presented-depth frontier, plus
// collapsed-tab augmentation which needs live element handles; neither walk publishes a
// presentation node.
var nodes: [RawAXNode] = []
let rootEvaluation = evaluateSnapshot(context.rootSnapshot)
nodes.append(
Expand All @@ -161,7 +162,12 @@ extension RunnerTests {
viewport: context.viewport
)
)
if context.maxDepth > 0 {
let shouldVisitRootChildren = SnapshotPresentation.shouldAcquireChildren(
for: hint,
rawDepth: 0,
regularPresentedDepth: 0
)
if shouldVisitRootChildren {
appendCollapsedTabFallbackNodes(
to: &nodes,
containerSnapshot: context.rootSnapshot,
Expand All @@ -173,21 +179,34 @@ extension RunnerTests {
}

var seen = Set<String>()
var stack: [SnapshotTraversalEntry] = context.rootSnapshot.children.map {
SnapshotTraversalEntry(
snapshot: $0,
depth: 1,
parentIndex: 0
)
var stack: [SnapshotTraversalEntry] = []
if shouldVisitRootChildren {
stack = context.rootSnapshot.children.map {
SnapshotTraversalEntry(
snapshot: $0,
depth: 1,
parentIndex: 0,
parentPresentedDepth: 0,
parentTraversal: .root
)
}
}

while let entry = stack.popLast() {
let snapshot = entry.snapshot
let depth = entry.depth
let parentIndex = entry.parentIndex
if let limit = hint.depth, depth > limit { continue }
if let limit = hint.rawTraversalDepth, depth > limit { continue }

let evaluation = evaluateSnapshot(snapshot)
let node = makeSnapshotNode(
snapshot: snapshot,
evaluation: evaluation,
depth: depth,
index: nodes.count,
parentIndex: parentIndex,
viewport: context.viewport
)
let key = Self.snapshotTraversalIdentity(
elementType: snapshot.elementType,
label: evaluation.label,
Expand All @@ -200,38 +219,41 @@ extension RunnerTests {
}

let currentIndex = !isDuplicate ? nodes.count : parentIndex
if depth < context.maxDepth {
let transition = SnapshotPresentation.regularTraversalTransition(
for: node,
parentPresentedDepth: entry.parentPresentedDepth,
parentTraversal: entry.parentTraversal,
hint: hint,
rawDepth: depth,
viewport: context.viewport,
hasChildren: !snapshot.children.isEmpty,
isDuplicate: isDuplicate,
policy: .platformDefault
)
if transition.shouldVisitChildren {
for child in snapshot.children.reversed() {
stack.append(
SnapshotTraversalEntry(
snapshot: child,
depth: depth + 1,
parentIndex: currentIndex
parentIndex: currentIndex,
parentPresentedDepth: transition.presentedDepth,
parentTraversal: transition.traversal
)
)
}
}

if isDuplicate { continue }

let index = nodes.count
nodes.append(
makeSnapshotNode(
snapshot: snapshot,
evaluation: evaluation,
depth: depth,
index: index,
parentIndex: parentIndex,
viewport: context.viewport
)
)
if depth < context.maxDepth {
nodes.append(node)
if transition.shouldVisitChildren {
appendCollapsedTabFallbackNodes(
to: &nodes,
containerSnapshot: snapshot,
resolveElements: collapsedTabDescendants,
depth: depth + 1,
parentIndex: index,
parentIndex: node.index,
viewport: context.viewport
)
}
Expand Down Expand Up @@ -349,7 +371,7 @@ extension RunnerTests {
var nodes: [RawAXNode] = []

func walk(_ snapshot: XCUIElementSnapshot, depth: Int, parentIndex: Int?) throws {
if let limit = hint.depth, depth > limit { return }
if let limit = hint.rawTraversalDepth, depth > limit { return }

let evaluation = evaluateSnapshot(snapshot)
if nodes.count >= Self.rawSnapshotMaxNodes {
Expand Down Expand Up @@ -391,7 +413,7 @@ extension RunnerTests {
var nodes: [RawAXNode] = [
interactiveRootNode(rect: .zero)
]
if hint.depth == 0 {
if hint.rawTraversalDepth == 0 || hint.regularPresentedDepth == 0 {
return SnapshotAcquisition(
hint: hint,
nodes: nodes,
Expand Down Expand Up @@ -831,8 +853,7 @@ extension RunnerTests {
return SnapshotTraversalContext(
queryRoot: app,
rootSnapshot: rootSnapshot,
viewport: viewport,
maxDepth: hint.depth ?? Int.max
viewport: viewport
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ enum SnapshotBackendEnvironment {
case physicalDevice
}

enum SnapshotRegularDepthCapability: String {
/// The backend can stop acquisition at the requested regular presented-depth frontier.
case presentedFrontier = "presented-frontier"
/// The backend is flat; it can answer the root and one presented level, but has no hierarchy
/// from which to prove deeper regular depth.
case flat
/// The backend can return raw traversal depth, but cannot prove regular presented depth.
case rawOnly = "raw-only"
}

enum SnapshotBackendKind: String, CaseIterable {
case recursiveTree = "tree"
case querySweep = "queries"
Expand Down Expand Up @@ -45,6 +55,29 @@ enum SnapshotBackendKind: String, CaseIterable {
}
}

var regularDepthCapability: SnapshotRegularDepthCapability {
switch self {
case .recursiveTree:
return .presentedFrontier
case .querySweep:
return .flat
case .privateAX:
return .rawOnly
}
}

func canServeRegularPresentedDepth(_ requestedDepth: Int?) -> Bool {
guard let requestedDepth else { return true }
switch regularDepthCapability {
case .presentedFrontier:
return true
case .flat:
return requestedDepth <= 1
case .rawOnly:
return false
}
}

var isAvailableOnCurrentPlatform: Bool {
#if os(iOS) && targetEnvironment(simulator)
return isAvailable(on: .simulator)
Expand Down Expand Up @@ -74,6 +107,7 @@ private struct SnapshotBackendParityFixture: Decodable {
let name: String
let forceable: Bool
let supportsRawProjection: Bool
let regularDepth: String
let hittable: String
let availability: Availability
}
Expand All @@ -99,7 +133,7 @@ extension RunnerTests {
}

/// The JSON table is the cross-runtime declaration used by the TypeScript capability registry
/// and this runner. A backend case, forceability branch, raw projection claim, or availability
/// and this runner. A backend case, forceability branch, projection/depth claim, or availability
/// change that is not classified in both implementations fails before an iOS smoke can drift.
func testSnapshotBackendDeclarationsMatchCapabilityFixture() throws {
let fixture = try loadSnapshotBackendParityFixture()
Expand All @@ -115,6 +149,11 @@ extension RunnerTests {
}
XCTAssertEqual(backend.isForceable, expected.forceable, expected.name)
XCTAssertEqual(backend.supportsRawProjection, expected.supportsRawProjection, expected.name)
XCTAssertEqual(
backend.regularDepthCapability.rawValue,
expected.regularDepth,
"regular depth capability: \(expected.name)"
)
XCTAssertEqual(
backend.hittableSemantics,
expected.hittable,
Expand All @@ -132,5 +171,14 @@ extension RunnerTests {
)
}
}

func testRegularDepthCapabilityDoesNotClaimFlatOrRawOnlyParity() {
XCTAssertTrue(SnapshotBackendKind.recursiveTree.canServeRegularPresentedDepth(8))
XCTAssertTrue(SnapshotBackendKind.querySweep.canServeRegularPresentedDepth(0))
XCTAssertTrue(SnapshotBackendKind.querySweep.canServeRegularPresentedDepth(1))
XCTAssertFalse(SnapshotBackendKind.querySweep.canServeRegularPresentedDepth(2))
XCTAssertFalse(SnapshotBackendKind.privateAX.canServeRegularPresentedDepth(1))
XCTAssertTrue(SnapshotBackendKind.privateAX.canServeRegularPresentedDepth(nil))
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,22 @@ extension RunnerTests {
) throws -> SnapshotBackendAttempt {
let hint = SnapshotPresentation.captureHint(for: options)
var timer = SnapshotPhaseTimer()
// Scoped depth is relative to a presentation-selected root, so its hint stays broad and the
// backend capability gate applies only to an unscoped regular frontier.
let requestedRegularDepth = options.raw || SnapshotScopePolicy.isActive(options.scope)
? nil
: options.depth
guard kind.canServeRegularPresentedDepth(requestedRegularDepth) else {
NSLog(
"AGENT_DEVICE_RUNNER_SNAPSHOT_BACKEND_DEPTH_UNSUPPORTED backend=%@ depth=%ld",
kind.rawValue,
requestedRegularDepth ?? -1
)
return SnapshotBackendAttempt(
outcome: .noCapture,
timing: timer.timing
)
}
let acquisition: SnapshotAcquisition?
do {
acquisition = try timer.measure(.acquisition) {
Expand Down
Loading
Loading