fix(ios): serve regular --depth from every snapshot backend (#2431)

* fix(ios): serve regular --depth from every snapshot backend

A regular depth-capped request was refused on every runner backend but the
recursive tree: the query sweep past depth 1 and private AX at any depth
returned no capture, so a plan pinned or deferred to private AX (custom
actions, a private AX verdict on the session, the XCTest channel penalty)
fell through to the synthetic sparse root, which the daemon then rejected as
"regular iOS snapshot presentation requires a valid viewport".

Presentation already applies the presented-depth cut to whatever hierarchy a
backend acquired, and a depth-capped regular capture is a subset of the
unscoped one from the same backend, so the refusal protected nothing the
unscoped answer did not already disclose through truncated/effectiveDepth.
Delete the gate, declare private AX as regular-depth=presentation-cut, and
record the rule in ADR 0004.

Closes #2403

* test(ios): prove a private-AX-pinned plan serves regular --depth through acquisition

The presentation-package test passes with the old backend depth gate restored,
because it calls presentation directly. This runner-bundle test pins private
AX, asks for regular depth 1 against the launched host app, and requires the
plan to reach acquisition and presentation: a private-ax verdict that is not
sparse, more than one node, a real root rect, and a payload no larger than the
unscoped capture from the same backend. With the gate restored the plan logs
SNAPSHOT_BACKEND_DEPTH_UNSUPPORTED and returns the zero-rect sparse root, and
the test fails.
This commit is contained in:
Michał Pierzchała
2026-09-10 11:21:01 +02:00
committed by GitHub
parent 6ca66c9fad
commit bd42b2602f
9 changed files with 150 additions and 53 deletions
+2 -1
View File
@@ -241,7 +241,8 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCustomActionCoverageParsesOnlyCompletePairs \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPartialCustomActionPassIsDisclosedAndCompleteOneIsNot \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testActionNamesAreCappedPerElementAndReported \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers 2>&1 | tee /tmp/agent-device-runner-regressions.log
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXPinnedRegularDepthReachesAcquisitionAndPresentation 2>&1 | tee /tmp/agent-device-runner-regressions.log
node --input-type=module -e '
import { readFileSync } from "node:fs";
const log = readFileSync("/tmp/agent-device-runner-regressions.log", "utf8");
+9
View File
@@ -2,6 +2,15 @@
## Unreleased
- Fixed: iOS `--depth` on `snapshot`, `is`, `wait`, `get`, and `find` no longer fails with
`regular iOS snapshot presentation requires a valid viewport` when the runner plan is pinned or
deferred to the private AX backend (custom actions, a private AX verdict on the session, or the
XCTest channel penalty). The runner refused a regular depth-capped request on every backend but
the recursive tree, fell through to its synthetic sparse root, and the daemon rejected that root
as a missing viewport. Presentation applies the presented-depth cut to whatever hierarchy a
backend acquired, so every backend serves the request; the private AX declaration is now
`regular-depth=presentation-cut` and an acquisition that stopped short of the cut keeps
disclosing that through `truncated`/`effectiveDepth` as it does unscoped.
- Added: `replay export` supports flows that switch apps and return, preserving each
`open <appId>` target as an explicit Maestro `launchApp.appId`.
- Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing
@@ -5,14 +5,19 @@ enum SnapshotBackendEnvironment {
case physicalDevice
}
/// How a backend's acquisition relates to a regular `--depth` request. Every backend serves the
/// request: `SnapshotPresentation` applies the presented-depth cut to whatever hierarchy was
/// acquired, and an acquisition that stopped short of the cut discloses that through its own
/// truncation verdict. The capability only says how much acquisition work the request bounds.
enum SnapshotRegularDepthCapability: String {
/// The backend can stop acquisition at the requested regular presented-depth frontier.
/// Acquisition stops 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.
/// The backend is flat: it acquires the root and one presented level, so a cut past depth 1
/// returns the sweep unchanged.
case flat
/// The backend can return raw traversal depth, but cannot prove regular presented depth.
case rawOnly = "raw-only"
/// Acquisition walks its raw-depth ladder regardless of the request; the presented cut happens
/// in presentation and the ladder's cap is disclosed as `effectiveDepth`.
case presentationCut = "presentation-cut"
}
enum SnapshotBackendKind: String, CaseIterable {
@@ -62,19 +67,7 @@ enum SnapshotBackendKind: String, CaseIterable {
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
return .presentationCut
}
}
@@ -171,14 +164,5 @@ 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
@@ -415,22 +415,6 @@ 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) {
@@ -1192,5 +1176,52 @@ extension RunnerTests {
)
)
}
#if os(iOS)
/// #2403: a plan pinned to private AX serves a regular `--depth` request through acquisition
/// and presentation. With a backend depth gate in `captureWithBackend`, private AX returns no
/// capture, the plan falls through to the synthetic sparse root, and the daemon rejects that
/// zero-rect root as a missing viewport.
func testPrivateAXPinnedRegularDepthReachesAcquisitionAndPresentation() throws {
app.launchArguments = ["--agent-device-selector-read-regression"]
app.launch()
currentApp = app
currentBundleId = nil
defer {
currentApp = nil
clearPrivateAXAcceptedDepth(reason: "test-cleanup")
app.terminate()
}
func capture(depth: Int?) throws -> DataPayload {
try runSnapshotCapturePlan(
Self.regularVisiblePlan,
app: app,
options: PresentationOptions(
interactiveOnly: false,
depth: depth,
scope: nil,
raw: false,
preferredBackend: SnapshotBackendKind.privateAX.rawValue
),
terminal: .sparseWithFatalOnAXFailure
)
}
let capped = try capture(depth: 1)
let quality = try XCTUnwrap(capped.snapshotQuality)
XCTAssertEqual(quality.backend, SnapshotBackendKind.privateAX.rawValue)
XCTAssertNotEqual(quality.state, "sparse")
let nodes = try XCTUnwrap(capped.nodes)
XCTAssertGreaterThan(nodes.count, 1)
XCTAssertEqual(nodes.map(\.depth).max(), 1)
XCTAssertNotEqual(nodes[0].rect, SnapshotRect(x: 0, y: 0, width: 0, height: 0))
XCTAssertTrue(nodes.contains { $0.label == "Readable target" })
// The presented cut only ever narrows the unscoped capture from the same backend.
let unscoped = try XCTUnwrap(try capture(depth: nil).nodes)
XCTAssertLessThanOrEqual(nodes.count, unscoped.count)
}
#endif
}
#endif
@@ -0,0 +1,59 @@
import AgentDeviceSnapshotPresentation
import CoreGraphics
import XCTest
/// A backend that acquires past the requested frontier (private AX walks its raw ladder) still
/// serves a regular `--depth` request: the cut is presentation's, applied to whatever hierarchy
/// was acquired (#2403).
final class RegularDepthTests: XCTestCase {
func testRegularDepthCutsAHierarchyAcquiredPastTheFrontier() throws {
let options = PresentationOptions(interactiveOnly: false, depth: 1, scope: nil, raw: false)
let viewport = CGRect(x: 0, y: 0, width: 100, height: 100)
// Application > Other(wrapper) > Button "Continue" > StaticText "Deep"
let nodes = [
node(0, type: "Application", label: "App", depth: 0, parentIndex: nil),
node(1, type: "Other", label: nil, depth: 1, parentIndex: 0),
node(2, type: "Button", label: "Continue", depth: 2, parentIndex: 1, hittable: true),
node(3, type: "StaticText", label: "Deep", depth: 3, parentIndex: 2),
]
let acquisition = SnapshotAcquisition(
hint: SnapshotPresentation.captureHint(for: options),
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: viewport
)
let result = try XCTUnwrap(SnapshotPresentation.present(acquisition, options: options))
XCTAssertEqual(result.nodes.map(\.label), ["App", "Continue"])
XCTAssertEqual(result.nodes.map(\.depth), [0, 1])
XCTAssertEqual(result.nodes.map(\.parentIndex), [nil, 0])
}
private func node(
_ index: Int,
type: String,
label: String?,
depth: Int,
parentIndex: Int?,
hittable: Bool = false
) -> RawAXNode {
RawAXNode(
index: index,
type: type,
label: label,
identifier: nil,
value: nil,
rect: SnapshotRect(x: 10, y: 10, width: 40, height: 20),
enabled: true,
focused: nil,
selected: nil,
hittable: hittable,
depth: depth,
parentIndex: parentIndex,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
}
}
@@ -39,7 +39,7 @@
"name": "private-ax",
"forceable": true,
"supportsRawProjection": true,
"regularDepth": "raw-only",
"regularDepth": "presentation-cut",
"hittable": "geometric-actionability",
"deepExtension": "yes",
"depthLadder": "yes",
+12 -5
View File
@@ -273,11 +273,18 @@ fold and eligibility collapse. This keeps shallow probes bounded by the requeste
frontier without inventing a raw-depth multiplier. Scoped captures remain broad because depth is
relative to the scope root selected in presentation.
Backend capability declarations are part of the contract: recursive tree supports the presented
frontier, the flat query sweep supports only its root and one presented level, and private AX is
raw-depth-only for regular depth requests until it has an equivalent hierarchy-aware frontier.
The capture plan does not claim deeper regular-depth completeness from a backend that cannot prove
it. Raw depth remains acquisition depth for every backend.
Backend capability declarations are part of the contract, and they describe how much acquisition
work a regular depth request bounds — never whether the backend may answer it. Every backend
serves a regular `--depth` request because presentation applies the presented-depth cut to
whatever hierarchy was acquired: the recursive tree stops acquisition at the presented frontier,
the flat query sweep has only its root and one presented level (so a cut past depth 1 returns the
sweep unchanged), and private AX walks its raw-depth ladder and is cut afterwards
(`presentation-cut`). Completeness below an acquisition cap is disclosed the same way it is for an
unscoped capture — through `truncated` and `effectiveDepth` — because a depth-capped regular
capture is a subset of the unscoped one from the same backend. Refusing the request instead
produced no answer at all: a plan pinned or deferred to private AX fell through to the synthetic
sparse root, which the daemon then rejected as a missing viewport (#2403). Raw depth remains
acquisition depth for every backend.
Acquisition-side limits remain explicit: raw private-AX captures still disclose their bridge-side
node cap, the flat query sweep still drops frameless elements because it has no hierarchy to attach
@@ -100,7 +100,7 @@ test('iOS snapshot registry classifies every backend and conformance target', ()
expect(SNAPSHOT_BACKEND_CAPABILITIES['private-ax']).toMatchObject({
forceable: true,
supportsRawProjection: true,
regularDepth: 'raw-only',
regularDepth: 'presentation-cut',
hittable: 'geometric-actionability',
deepExtension: 'yes',
depthLadder: 'yes',
@@ -6,7 +6,13 @@ import type {
/** #1933: every classified backend publishes this shared predicate in the wire `hittable` field. */
type SnapshotBackendHittable = 'geometric-actionability';
type SnapshotBackendSupport = 'yes' | 'no' | 'n/a';
type SnapshotRegularDepthCapability = 'presented-frontier' | 'flat' | 'raw-only';
/**
* How a backend's acquisition relates to a regular `--depth` request. Presentation applies the
* presented-depth cut to whatever hierarchy was acquired, so every backend serves the request;
* the value says whether acquisition stops at the frontier, is flat, or walks its raw ladder and
* is cut afterwards.
*/
type SnapshotRegularDepthCapability = 'presented-frontier' | 'flat' | 'presentation-cut';
type SnapshotBackendCapability = {
supportsRawProjection: boolean;
@@ -57,7 +63,7 @@ export const SNAPSHOT_BACKEND_CAPABILITIES = {
'private-ax': {
forceable: true,
supportsRawProjection: true,
regularDepth: 'raw-only',
regularDepth: 'presentation-cut',
hittable: 'geometric-actionability',
deepExtension: 'yes',
depthLadder: 'yes',