Skip to content

Commit aa741b4

Browse files
authored
fix: recover depth-limited iOS snapshots after AX failures (#706)
* fix: recover depth-limited iOS snapshots after AX failures * refactor: tighten iOS snapshot AX fallback * fix: preserve iOS AX fallback guidance
1 parent 5c083ea commit aa741b4

1 file changed

Lines changed: 183 additions & 36 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift

Lines changed: 183 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import XCTest
22

33
extension RunnerTests {
44
private static let axSnapshotErrorCode = "IOS_AX_SNAPSHOT_FAILED"
5+
private static let axSnapshotFailureMessage =
6+
"iOS XCTest snapshot failed while serializing the accessibility tree."
7+
private static let axSnapshotUnavailableReason = "ax_snapshot_unavailable"
58
private static let axSnapshotHint =
69
"Snapshot state is unavailable because XCTest could not serialize this iOS accessibility tree. This can be specific to the current screen. Use plain screenshot, not screenshot --overlay-refs, as visual truth; navigate with coordinate commands if needed; then retry snapshot -i after reaching another screen. If you own the app and need full-tree inspection, simplify this screen's accessibility tree and expose stable ids on actionable controls."
710
private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
@@ -37,6 +40,12 @@ extension RunnerTests {
3740
let visible: Bool
3841
}
3942

43+
private enum SnapshotTraversalCapture {
44+
case context(SnapshotTraversalContext)
45+
case fallback(DataPayload)
46+
case empty
47+
}
48+
4049
struct SnapshotCaptureFailure: Error {
4150
let code: String
4251
let message: String
@@ -93,14 +102,18 @@ extension RunnerTests {
93102
return blocking
94103
}
95104

96-
let context: SnapshotTraversalContext?
97-
do {
98-
context = try makeSnapshotTraversalContext(app: app, options: options)
99-
} catch let failure as SnapshotCaptureFailure where options.interactiveOnly {
100-
return snapshotAccessibilityUnavailable(failure: failure)
101-
}
102-
103-
guard let context else {
105+
let capture = try captureSnapshotTraversalContext(
106+
app: app,
107+
options: options,
108+
allowInteractiveUnavailableFallback: true
109+
)
110+
let context: SnapshotTraversalContext
111+
switch capture {
112+
case .context(let traversalContext):
113+
context = traversalContext
114+
case .fallback(let fallback):
115+
return fallback
116+
case .empty:
104117
return DataPayload(nodes: [], truncated: false)
105118
}
106119

@@ -211,7 +224,18 @@ extension RunnerTests {
211224
return blocking
212225
}
213226

214-
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
227+
let capture = try captureSnapshotTraversalContext(
228+
app: app,
229+
options: options,
230+
allowInteractiveUnavailableFallback: false
231+
)
232+
let context: SnapshotTraversalContext
233+
switch capture {
234+
case .context(let traversalContext):
235+
context = traversalContext
236+
case .fallback(let fallback):
237+
return fallback
238+
case .empty:
215239
return DataPayload(nodes: [], truncated: false)
216240
}
217241

@@ -270,6 +294,7 @@ extension RunnerTests {
270294
let deadline = options.interactiveOnly
271295
? Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
272296
: Date.distantFuture
297+
let viewport = safeSnapshotViewport(app: app)
273298
var seen = Set<String>()
274299
var candidates: [SnapshotNode] = []
275300
for element in flatInteractiveElements(app: app, deadline: deadline) {
@@ -281,7 +306,7 @@ extension RunnerTests {
281306
element: element,
282307
index: 0,
283308
parentIndex: 0,
284-
viewport: .infinite,
309+
viewport: viewport,
285310
options: options
286311
) else {
287312
continue
@@ -329,13 +354,91 @@ extension RunnerTests {
329354

330355
private func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
331356
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
332-
invalidateCachedTarget(reason: "ax_snapshot_unavailable")
357+
invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
358+
return sparseTruncatedSnapshotPayload(
359+
message: recoveredSnapshotMessage(failure),
360+
runnerFatal: true,
361+
runnerFatalReason: Self.axSnapshotUnavailableReason
362+
)
363+
}
364+
365+
private func captureSnapshotTraversalContext(
366+
app: XCUIApplication,
367+
options: SnapshotOptions,
368+
allowInteractiveUnavailableFallback: Bool
369+
) throws -> SnapshotTraversalCapture {
370+
do {
371+
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
372+
return .empty
373+
}
374+
return .context(context)
375+
} catch let failure as SnapshotCaptureFailure {
376+
if let fallback = snapshotDepthLimitedAccessibilityFallback(
377+
app: app,
378+
options: options,
379+
failure: failure
380+
) {
381+
return .fallback(fallback)
382+
}
383+
if allowInteractiveUnavailableFallback && options.interactiveOnly {
384+
return .fallback(snapshotAccessibilityUnavailable(failure: failure))
385+
}
386+
throw failure
387+
}
388+
}
389+
390+
private func snapshotDepthLimitedAccessibilityFallback(
391+
app: XCUIApplication,
392+
options: SnapshotOptions,
393+
failure: SnapshotCaptureFailure
394+
) -> DataPayload? {
395+
guard let requestedDepth = options.depth else {
396+
return nil
397+
}
398+
399+
NSLog(
400+
"AGENT_DEVICE_RUNNER_SNAPSHOT_DEPTH_FALLBACK=%@",
401+
failure.message
402+
)
403+
404+
if requestedDepth <= 0 {
405+
return sparseTruncatedSnapshotPayload(message: recoveredSnapshotMessage(failure))
406+
}
407+
408+
// Raw depth-limited recovery intentionally falls back to sparse interactive discovery because
409+
// the raw AX tree is the failed operation.
410+
let fallback = snapshotFlatInteractive(
411+
app: app,
412+
options: SnapshotOptions(
413+
interactiveOnly: true,
414+
compact: options.compact,
415+
depth: requestedDepth,
416+
scope: options.scope,
417+
raw: false
418+
)
419+
)
420+
return DataPayload(
421+
message: recoveredSnapshotMessage(failure),
422+
nodes: fallback.nodes,
423+
truncated: true
424+
)
425+
}
426+
427+
private func recoveredSnapshotMessage(_ failure: SnapshotCaptureFailure) -> String {
428+
return "\(failure.message) Hint: \(failure.hint)"
429+
}
430+
431+
private func sparseTruncatedSnapshotPayload(
432+
message: String,
433+
runnerFatal: Bool? = nil,
434+
runnerFatalReason: String? = nil
435+
) -> DataPayload {
333436
return DataPayload(
334-
message: failure.message,
437+
message: message,
335438
nodes: [compactInteractiveRootNode(rect: .zero)],
336439
truncated: true,
337-
runnerFatal: true,
338-
runnerFatalReason: "ax_snapshot_unavailable"
440+
runnerFatal: runnerFatal,
441+
runnerFatalReason: runnerFatalReason
339442
)
340443
}
341444

@@ -345,22 +448,68 @@ extension RunnerTests {
345448

346449
let payload = snapshotAccessibilityUnavailable(
347450
failure: SnapshotCaptureFailure(
348-
code: "IOS_AX_SNAPSHOT_FAILED",
349-
message: "iOS XCTest snapshot failed while serializing the accessibility tree.",
451+
code: Self.axSnapshotErrorCode,
452+
message: Self.axSnapshotFailureMessage,
350453
hint: Self.axSnapshotHint
351454
)
352455
)
353456

354-
XCTAssertEqual(payload.message, "iOS XCTest snapshot failed while serializing the accessibility tree.")
457+
XCTAssertEqual(payload.message, "\(Self.axSnapshotFailureMessage) Hint: \(Self.axSnapshotHint)")
355458
XCTAssertEqual(payload.nodes?.count, 1)
356459
XCTAssertEqual(payload.nodes?.first?.type, "Application")
357460
XCTAssertEqual(payload.truncated, true)
358461
XCTAssertEqual(payload.runnerFatal, true)
359-
XCTAssertEqual(payload.runnerFatalReason, "ax_snapshot_unavailable")
462+
XCTAssertEqual(payload.runnerFatalReason, Self.axSnapshotUnavailableReason)
360463
XCTAssertNil(currentApp)
361464
XCTAssertNil(currentBundleId)
362465
}
363466

467+
func testRecoveredSnapshotMessagePreservesHint() {
468+
let message = recoveredSnapshotMessage(
469+
SnapshotCaptureFailure(
470+
code: Self.axSnapshotErrorCode,
471+
message: Self.axSnapshotFailureMessage,
472+
hint: Self.axSnapshotHint
473+
)
474+
)
475+
476+
XCTAssertTrue(message.contains(Self.axSnapshotFailureMessage))
477+
XCTAssertTrue(message.contains(Self.axSnapshotHint))
478+
}
479+
480+
func testDepthLimitedSnapshotFailureReturnsNonFatalFallback() {
481+
currentApp = app
482+
currentBundleId = "com.example.app"
483+
484+
let payload = snapshotDepthLimitedAccessibilityFallback(
485+
app: app,
486+
options: SnapshotOptions(
487+
interactiveOnly: false,
488+
compact: false,
489+
depth: 0,
490+
scope: nil,
491+
raw: false
492+
),
493+
failure: SnapshotCaptureFailure(
494+
code: Self.axSnapshotErrorCode,
495+
message: "\(Self.axSnapshotFailureMessage) kAXErrorIllegalArgument.",
496+
hint: Self.axSnapshotHint
497+
)
498+
)
499+
500+
XCTAssertEqual(
501+
payload?.message,
502+
"\(Self.axSnapshotFailureMessage) kAXErrorIllegalArgument. Hint: \(Self.axSnapshotHint)"
503+
)
504+
XCTAssertEqual(payload?.nodes?.count, 1)
505+
XCTAssertEqual(payload?.nodes?.first?.type, "Application")
506+
XCTAssertEqual(payload?.truncated, true)
507+
XCTAssertNil(payload?.runnerFatal)
508+
XCTAssertNil(payload?.runnerFatalReason)
509+
XCTAssertNotNil(currentApp)
510+
XCTAssertEqual(currentBundleId, "com.example.app")
511+
}
512+
364513
private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode {
365514
SnapshotNode(
366515
index: 0,
@@ -507,11 +656,12 @@ extension RunnerTests {
507656
}
508657

509658
private func axSnapshotFailure(_ message: String) -> SnapshotCaptureFailure {
659+
let detail = message.trimmingCharacters(in: .whitespacesAndNewlines)
510660
let failureMessage: String
511-
if Self.hasAxIllegalArgumentCode(message) {
512-
failureMessage = "iOS XCTest snapshot failed with kAXErrorIllegalArgument. \(message)"
661+
if detail.isEmpty {
662+
failureMessage = Self.axSnapshotFailureMessage
513663
} else {
514-
failureMessage = "iOS XCTest snapshot failed while serializing the accessibility tree. \(message)"
664+
failureMessage = "\(Self.axSnapshotFailureMessage) \(detail)"
515665
}
516666
return SnapshotCaptureFailure(
517667
code: Self.axSnapshotErrorCode,
@@ -522,14 +672,10 @@ extension RunnerTests {
522672

523673
private static func isAxIllegalArgument(_ message: String) -> Bool {
524674
let normalized = message.lowercased()
525-
return hasAxIllegalArgumentCode(normalized)
675+
return normalized.contains("kaxerrorillegalargument")
526676
|| (normalized.contains("illegal argument") && normalized.contains("snapshot"))
527677
}
528678

529-
private static func hasAxIllegalArgumentCode(_ message: String) -> Bool {
530-
return message.lowercased().contains("kaxerrorillegalargument")
531-
}
532-
533679
private func evaluateSnapshot(
534680
_ snapshot: XCUIElementSnapshot,
535681
in context: SnapshotTraversalContext
@@ -632,8 +778,13 @@ extension RunnerTests {
632778

633779
private func snapshotViewport(app: XCUIApplication) -> CGRect {
634780
let windows = app.windows.allElementsBoundByIndex
635-
if let window = windows.first(where: { $0.exists && !$0.frame.isNull && !$0.frame.isEmpty }) {
636-
return window.frame
781+
let windowFrames = windows
782+
.filter { $0.exists && !$0.frame.isNull && !$0.frame.isEmpty }
783+
.map(\.frame)
784+
if let largestWindowFrame = windowFrames.max(by: { left, right in
785+
left.width * left.height < right.width * right.height
786+
}) {
787+
return largestWindowFrame
637788
}
638789
let appFrame = app.frame
639790
if !appFrame.isNull && !appFrame.isEmpty {
@@ -872,7 +1023,9 @@ extension RunnerTests {
8721023
app.pickers,
8731024
app.steppers,
8741025
app.tabBars,
875-
app.menuItems
1026+
app.menuItems,
1027+
app.staticTexts,
1028+
app.images
8761029
]
8771030

8781031
var elements: [XCUIElement] = []
@@ -920,6 +1073,7 @@ extension RunnerTests {
9201073
let frame = element.frame
9211074
if frame.isNull || frame.isEmpty { return }
9221075
let visible = isVisibleInViewport(frame, viewport)
1076+
if options.interactiveOnly && !visible { return }
9231077
#if os(macOS)
9241078
if !visible { return }
9251079
#endif
@@ -964,13 +1118,6 @@ extension RunnerTests {
9641118
return node
9651119
}
9661120

967-
private func nonEmptyElementText(_ read: () -> String) -> String? {
968-
let value = safely("SNAPSHOT_FLAT_TEXT", "") {
969-
read()
970-
}.trimmingCharacters(in: .whitespacesAndNewlines)
971-
return value.isEmpty ? nil : value
972-
}
973-
9741121
private func isScrollableContainer(_ snapshot: XCUIElementSnapshot, visible: Bool) -> Bool {
9751122
if !visible { return false }
9761123
if !Self.scrollContainerTypes.contains(snapshot.elementType) { return false }

0 commit comments

Comments
 (0)