mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
6ca66c9fad
commit
bd42b2602f
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-27
@@ -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
|
||||
|
||||
+47
-16
@@ -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
|
||||
|
||||
+59
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user