@@ -2,6 +2,9 @@ import XCTest
22
33extension 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