Files
callstack__agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotPresentationInvariantTests.swift
Michał Pierzchała 7f3e355426 fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
* fix(ios): complete regular snapshot depth frontier

* fix(ios): align depth frontier with visibility fold

* fix(ios): exercise regular depth frontier in CI

* fix(ios): cover visible-depth frontier through public snapshot

* fix(ios): tolerate absent deep-link confirmation

* test(ios): expose visible-depth fixture hierarchy

* test(ios): wait for visible-depth fixture subtree

* fix(ios): keep visible-depth fixture minimal

* fix(ios): update snapshot hint fixtures

* test(ios): avoid fixture label aggregation

* test(ios): match fixture raw hierarchy

* test(ios): prove visible-depth raw ancestry

* test(ios): align depth smoke with AX hierarchy
2026-08-22 13:53:39 +02:00

381 lines
12 KiB
Swift

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
import XCTest
extension RunnerTests {
private struct FixedSeedGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func nextInt(_ upperBound: Int) -> Int {
precondition(upperBound > 0)
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
return Int(state % UInt64(upperBound))
}
}
private static func invariantNode(
_ index: Int,
type: String,
label: String? = nil,
rect: SnapshotRect,
parentIndex: Int?,
hittable: Bool = false
) -> RawAXNode {
RawAXNode(
index: index,
type: type,
label: label,
identifier: nil,
value: nil,
rect: rect,
enabled: true,
focused: nil,
selected: nil,
hittable: hittable,
depth: parentIndex == nil ? 0 : 1,
parentIndex: parentIndex,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
}
private static func fixedSeedAcquisition(seed: UInt64) -> SnapshotAcquisition {
var generator = FixedSeedGenerator(seed: seed)
let viewport = CGRect(x: 0, y: 0, width: 320, height: 240)
var nodes = [
invariantNode(
0,
type: "Application",
label: "App",
rect: SnapshotRect(x: 0, y: 0, width: 320, height: 240),
parentIndex: nil
),
invariantNode(
1,
type: "ScrollView",
label: "Outer scroll",
rect: SnapshotRect(x: 16, y: 20, width: 220, height: 180),
parentIndex: 0
),
invariantNode(
2,
type: "ScrollView",
label: "Inner scroll",
rect: SnapshotRect(x: 120, y: 40, width: 180, height: 160),
parentIndex: 1
),
]
for index in 3...18 {
let parentIndex = generator.nextInt(index)
let type = generator.nextInt(4) == 0 ? "StaticText" : "Button"
let width = generator.nextInt(120) + 1
let height = generator.nextInt(48) + 1
let x = generator.nextInt(360) - 20
let y = generator.nextInt(280) - 20
nodes.append(
invariantNode(
index,
type: type,
label: "seed-\(seed)-\(index)",
rect: SnapshotRect(
x: Double(x), y: Double(y), width: Double(width), height: Double(height)),
parentIndex: parentIndex,
hittable: true
)
)
}
return SnapshotAcquisition(
hint: CaptureHint(
projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: false,
customActions: false),
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: viewport
)
}
func testRegularPresentationKeepsNestedClipGeometryCumulative() throws {
let acquisition = SnapshotAcquisition(
hint: CaptureHint(
projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: false,
customActions: false),
nodes: [
Self.invariantNode(
0,
type: "Application",
label: "App",
rect: SnapshotRect(x: 0, y: 0, width: 320, height: 240),
parentIndex: nil
),
Self.invariantNode(
1,
type: "ScrollView",
label: "Outer",
rect: SnapshotRect(x: 16, y: 20, width: 180, height: 180),
parentIndex: 0
),
Self.invariantNode(
2,
type: "ScrollView",
label: "Inner",
rect: SnapshotRect(x: 120, y: 40, width: 180, height: 160),
parentIndex: 1
),
Self.invariantNode(
3,
type: "Button",
label: "Partially visible",
rect: SnapshotRect(x: 150, y: 80, width: 100, height: 40),
parentIndex: 2,
hittable: true
),
Self.invariantNode(
4,
type: "Button",
label: "Escaped child",
rect: SnapshotRect(x: 210, y: 80, width: 100, height: 40),
parentIndex: 2,
hittable: true
),
],
truncated: false,
effectiveDepth: nil,
viewport: CGRect(x: 0, y: 0, width: 320, height: 240)
)
let options = PresentationOptions(
interactiveOnly: false, depth: nil, scope: nil, raw: false)
let capture = try SnapshotPresentation.presentRegular(
acquisition, options: options, policy: .cursorProjected)
let nodes = try XCTUnwrap(capture.payload.nodes)
XCTAssertEqual(nodes.compactMap(\.label), ["App", "Outer", "Inner", "Partially visible"])
let clipped = try XCTUnwrap(nodes.first { $0.label == "Partially visible" })
XCTAssertEqual(clipped.rect.x, 150)
XCTAssertEqual(clipped.rect.width, 46)
XCTAssertFalse(nodes.contains { $0.label == "Escaped child" })
}
func testRegularPresentationKeepsFramelessSemanticCarriersNonActionableAndNonClipping() throws {
let acquisition = SnapshotAcquisition(
hint: CaptureHint(
projection: .regular, depth: nil, regularPresentedDepth: nil, interactiveOnly: true,
customActions: false),
nodes: [
Self.invariantNode(
0,
type: "Application",
label: "App",
rect: SnapshotRect(x: 0, y: 0, width: 320, height: 240),
parentIndex: nil
),
Self.invariantNode(
1,
type: "ScrollView",
label: "Geometryless semantics",
rect: SnapshotRect(x: 20, y: 20, width: 0, height: 0),
parentIndex: 0,
hittable: true
),
Self.invariantNode(
2,
type: "Button",
label: "Child is not clipped",
rect: SnapshotRect(x: 40, y: 40, width: 80, height: 32),
parentIndex: 1,
hittable: true
),
Self.invariantNode(
3,
type: "StaticText",
label: "Zero-area semantics",
rect: SnapshotRect(x: 40, y: 100, width: 0, height: 20),
parentIndex: 0,
hittable: true
),
],
truncated: false,
effectiveDepth: nil,
viewport: CGRect(x: 0, y: 0, width: 320, height: 240)
)
let options = PresentationOptions(
interactiveOnly: true, depth: nil, scope: nil, raw: false)
let nodes = try XCTUnwrap(
try SnapshotPresentation.presentRegular(
acquisition, options: options, policy: .cursorProjected
).payload.nodes)
XCTAssertEqual(nodes.compactMap(\.label), [
"App", "Geometryless semantics", "Child is not clipped", "Zero-area semantics",
])
XCTAssertEqual(nodes.filter { $0.index != 0 }.map(\.hittable), [false, true, false])
}
func testRawPresentationKeepsReportedOffscreenAndFramelessFacts() throws {
let nodes = [
Self.invariantNode(
0,
type: "Application",
label: "App",
rect: SnapshotRect(x: 0, y: 0, width: 100, height: 100),
parentIndex: nil
),
Self.invariantNode(
1,
type: "Button",
label: "Offscreen",
rect: SnapshotRect(x: 200, y: 200, width: 40, height: 40),
parentIndex: 0,
hittable: true
),
Self.invariantNode(
2,
type: "StaticText",
label: "Frameless",
rect: SnapshotRect(x: 12, y: 20, width: 0, height: 20),
parentIndex: 0,
hittable: true
),
]
let options = PresentationOptions(
interactiveOnly: true, depth: nil, scope: nil, raw: true)
let raw = try XCTUnwrap(
SnapshotPresentation.presentRaw(
SnapshotAcquisition(
hint: SnapshotPresentation.captureHint(for: options),
nodes: nodes,
truncated: false,
effectiveDepth: nil,
viewport: .infinite
),
options: options
).payload.nodes)
XCTAssertEqual(raw.map(\.label), ["App", "Offscreen", "Frameless"])
XCTAssertEqual(raw[1].rect.x, 200)
XCTAssertEqual(raw[1].rect.width, 40)
XCTAssertEqual(raw[2].rect.width, 0)
XCTAssertEqual(raw[2].rect.height, 20)
XCTAssertEqual(raw[1].hittable, true)
XCTAssertEqual(raw[2].hittable, true)
}
func testFixedSeedRegularPresentationMaintainsCumulativeClipInvariant() throws {
// Fixed-seed coverage keeps the one-pass validator exercised across nested and degenerate
// ancestry without relying on a wall-clock performance threshold.
for seed in 1...64 {
let acquisition = Self.fixedSeedAcquisition(seed: UInt64(seed))
let options = PresentationOptions(
interactiveOnly: false, depth: nil, scope: nil, raw: false)
_ = try SnapshotPresentation.presentRegular(
acquisition, options: options, policy: .cursorProjected)
}
}
func testRegularInvariantRejectsEscapedEffectiveGeometry() throws {
let viewport = CGRect(x: 0, y: 0, width: 200, height: 200)
let rawNodes = [
Self.invariantNode(
0,
type: "Application",
rect: SnapshotRect(x: 0, y: 0, width: 200, height: 200),
parentIndex: nil
),
Self.invariantNode(
1,
type: "ScrollView",
rect: SnapshotRect(x: 20, y: 20, width: 100, height: 100),
parentIndex: 0
),
Self.invariantNode(
2,
type: "Button",
rect: SnapshotRect(x: 110, y: 40, width: 20, height: 20),
parentIndex: 1
),
]
let folded = rawNodes.map {
SnapshotPresentationNode(raw: $0, effectiveRect: $0.rect)
}
XCTAssertThrowsError(
try SnapshotPresentation.validateRegularInvariantForTesting(
folded,
viewport: viewport,
policy: .cursorProjected
)
) { error in
guard case let SnapshotPresentationFailure.regularNodeOutsideCumulativeClip(index, _, clip) = error
else {
return XCTFail("expected cumulative clip failure, got \(error)")
}
XCTAssertEqual(index, 2)
XCTAssertEqual(clip.x, 20)
XCTAssertEqual(clip.width, 100)
}
}
func testRegularInvariantUsesOneParentClipLookupPerNode() throws {
let nodeCount = 5_000
let viewport = CGRect(x: 0, y: 0, width: 100, height: 100)
let nodes = (0..<nodeCount).map { index in
let raw = Self.invariantNode(
index,
type: index == 0 ? "Application" : "ScrollView",
rect: SnapshotRect(x: 0, y: 0, width: 100, height: 100),
parentIndex: index == 0 ? nil : index - 1
)
return SnapshotPresentationNode(raw: raw, effectiveRect: raw.rect)
}
let stats = try SnapshotPresentation.validateRegularInvariantForTesting(
nodes,
viewport: viewport,
policy: .cursorProjected
)
// The former per-node ancestor walk performs 12,497,500 lookups for this chain. Counting the
// cached parent resolutions makes the linear guarantee deterministic without timing the test.
XCTAssertEqual(stats.parentClipLookups, nodeCount - 1)
}
func testPresentationFailureKeepsItsNamedSnapshotQualityReason() {
// Non-vacuity: dropping the typed reason preference in the plan mapper changes the assertion
// below to `capture-failed`, losing the distinction this contract is meant to preserve.
let presentationFailure = SnapshotPresentationFailure.regularNodeOutsideCumulativeClip(
index: 7,
frame: SnapshotRect(x: 150, y: 40, width: 80, height: 20),
clip: SnapshotRect(x: 0, y: 0, width: 100, height: 100)
)
let captureFailure = Self.snapshotCaptureFailure(for: presentationFailure)
XCTAssertEqual(captureFailure.code, "IOS_SNAPSHOT_PRESENTATION_FAILED")
XCTAssertEqual(captureFailure.qualityReasonCode, "presentation-failed")
XCTAssertEqual(Self.snapshotQualityReasonCode(for: captureFailure), "presentation-failed")
XCTAssertNotEqual(Self.snapshotQualityReasonCode(for: captureFailure), "capture-failed")
XCTAssertTrue(captureFailure.message.contains("cumulative clip"))
let warning = Self.legacyQualityMessage(
SnapshotQuality(
state: "recovered",
backend: "queries",
reason: captureFailure.message,
reasonCode: captureFailure.qualityReasonCode,
effectiveDepth: nil,
collapsedLeafIndexes: nil,
customActions: nil
)
)
XCTAssertTrue(warning?.contains("runner bug") == true)
XCTAssertFalse(warning?.contains("fixing the app's accessibility") == true)
}
}
#endif