@@ -16,6 +16,7 @@ extension RunnerTests {
1616 . scrollView,
1717 . table
1818 ]
19+ private static let flatInteractiveFallbackBudget : TimeInterval = 1.0
1920
2021 private struct SnapshotTraversalContext {
2122 let queryRoot : XCUIElement
@@ -85,11 +86,22 @@ extension RunnerTests {
8586 }
8687
8788 func snapshotFast( app: XCUIApplication , options: SnapshotOptions ) throws -> DataPayload {
89+ if options. interactiveOnly && options. compact {
90+ return snapshotFlatInteractive ( app: app, options: options)
91+ }
8892 if let blocking = blockingSystemAlertSnapshot ( ) {
8993 return blocking
9094 }
9195
92- guard let context = try makeSnapshotTraversalContext ( app: app, options: options) else {
96+ let context : SnapshotTraversalContext ?
97+ do {
98+ context = try makeSnapshotTraversalContext ( app: app, options: options)
99+ } catch let failure as SnapshotCaptureFailure where options. interactiveOnly {
100+ NSLog ( " AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK=%@ " , failure. message)
101+ return snapshotFlatInteractive ( app: app, options: options)
102+ }
103+
104+ guard let context else {
93105 return DataPayload ( nodes: [ ] , truncated: false )
94106 }
95107
@@ -248,6 +260,102 @@ extension RunnerTests {
248260 return DataPayload ( nodes: nodes, truncated: truncated)
249261 }
250262
263+ private func snapshotFlatInteractive( app: XCUIApplication , options: SnapshotOptions ) -> DataPayload {
264+ var nodes : [ SnapshotNode ] = [
265+ compactInteractiveRootNode ( rect: . zero)
266+ ]
267+ if options. depth == 0 {
268+ return DataPayload ( nodes: nodes, truncated: false )
269+ }
270+
271+ let deadline = options. interactiveOnly
272+ ? Date ( ) . addingTimeInterval ( Self . flatInteractiveFallbackBudget)
273+ : Date . distantFuture
274+ var seen = Set < String > ( )
275+ var candidates : [ SnapshotNode ] = [ ]
276+ for element in flatInteractiveElements ( app: app, deadline: deadline) {
277+ if Date ( ) >= deadline {
278+ NSLog ( " AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE " )
279+ break
280+ }
281+ guard let node = flatSnapshotNode (
282+ element: element,
283+ index: 0 ,
284+ parentIndex: 0 ,
285+ viewport: . infinite,
286+ options: options
287+ ) else {
288+ continue
289+ }
290+ let key = " \( node. type) - \( node. label ?? " " ) - \( node. identifier ?? " " ) - \( node. value ?? " " ) - \( node. rect. x) - \( node. rect. y) - \( node. rect. width) - \( node. rect. height) "
291+ if seen. contains ( key) { continue }
292+ seen. insert ( key)
293+ candidates. append ( node)
294+ }
295+ candidates. sort { left, right in
296+ if left. rect. y != right. rect. y {
297+ return left. rect. y < right. rect. y
298+ }
299+ if left. rect. x != right. rect. x {
300+ return left. rect. x < right. rect. x
301+ }
302+ return left. type < right. type
303+ }
304+
305+ let remaining = max ( 0 , fastSnapshotLimit - nodes. count)
306+ let truncated = candidates. count > remaining
307+ nodes [ 0 ] = compactInteractiveRootNode ( rect: compactInteractiveRootFrame ( for: candidates) )
308+ for candidate in candidates. prefix ( remaining) {
309+ nodes. append (
310+ SnapshotNode (
311+ index: nodes. count,
312+ type: candidate. type,
313+ label: candidate. label,
314+ identifier: candidate. identifier,
315+ value: candidate. value,
316+ rect: candidate. rect,
317+ enabled: candidate. enabled,
318+ focused: candidate. focused,
319+ selected: candidate. selected,
320+ hittable: candidate. hittable,
321+ depth: 1 ,
322+ parentIndex: 0 ,
323+ hiddenContentAbove: nil ,
324+ hiddenContentBelow: nil
325+ )
326+ )
327+ }
328+ return DataPayload ( nodes: nodes, truncated: truncated)
329+ }
330+
331+ private func compactInteractiveRootNode( rect: CGRect ) -> SnapshotNode {
332+ SnapshotNode (
333+ index: 0 ,
334+ type: " Application " ,
335+ label: nil ,
336+ identifier: nil ,
337+ value: nil ,
338+ rect: snapshotRect ( from: rect) ,
339+ enabled: true ,
340+ focused: nil ,
341+ selected: nil ,
342+ hittable: false ,
343+ depth: 0 ,
344+ parentIndex: nil ,
345+ hiddenContentAbove: nil ,
346+ hiddenContentBelow: nil
347+ )
348+ }
349+
350+ private func compactInteractiveRootFrame( for candidates: [ SnapshotNode ] ) -> CGRect {
351+ guard !candidates. isEmpty else {
352+ return . zero
353+ }
354+ let maxX = candidates. map { CGFloat ( $0. rect. x + $0. rect. width) } . max ( ) ?? 0
355+ let maxY = candidates. map { CGFloat ( $0. rect. y + $0. rect. height) } . max ( ) ?? 0
356+ return CGRect ( x: 0 , y: 0 , width: max ( 1 , maxX) , height: max ( 1 , maxY) )
357+ }
358+
251359 func snapshotRect( from frame: CGRect ) -> SnapshotRect {
252360 return SnapshotRect (
253361 x: Double ( frame. origin. x) ,
@@ -714,6 +822,107 @@ extension RunnerTests {
714822 safely ( " SNAPSHOT_QUERY " , [ ] , fetch)
715823 }
716824
825+ private func flatInteractiveElements(
826+ app: XCUIApplication ,
827+ deadline: Date
828+ ) -> [ XCUIElement ] {
829+ let queries : [ XCUIElementQuery ] = [
830+ app. buttons,
831+ app. links,
832+ app. textFields,
833+ app. secureTextFields,
834+ app. searchFields,
835+ app. textViews,
836+ app. switches,
837+ app. sliders,
838+ app. segmentedControls,
839+ app. cells,
840+ app. collectionViews,
841+ app. tables,
842+ app. scrollViews,
843+ app. pickers,
844+ app. steppers,
845+ app. tabBars,
846+ app. menuItems
847+ ]
848+
849+ var elements : [ XCUIElement ] = [ ]
850+ for query in queries {
851+ if Date ( ) >= deadline {
852+ NSLog ( " AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE " )
853+ break
854+ }
855+ elements. append ( contentsOf: safeSnapshotElementsQuery {
856+ query. allElementsBoundByIndex
857+ } )
858+ }
859+ return elements
860+ }
861+
862+ private func flatSnapshotNode(
863+ element: XCUIElement ,
864+ index: Int ,
865+ parentIndex: Int ? ,
866+ viewport: CGRect ,
867+ options: SnapshotOptions
868+ ) -> SnapshotNode ? {
869+ var node : SnapshotNode ?
870+ let exceptionMessage = RunnerObjCExceptionCatcher . catchException ( {
871+ if !element. exists { return }
872+ let frame = element. frame
873+ if frame. isNull || frame. isEmpty { return }
874+ let visible = isVisibleInViewport ( frame, viewport)
875+ #if os(macOS)
876+ if !visible { return }
877+ #endif
878+ let label = element. label. trimmingCharacters ( in: . whitespacesAndNewlines)
879+ let identifier = element. identifier. trimmingCharacters ( in: . whitespacesAndNewlines)
880+ let valueText = snapshotValueText ( element)
881+ let hasContent = !label. isEmpty || !identifier. isEmpty || valueText != nil
882+ let elementType = element. elementType
883+ let enabled = element. isEnabled
884+ let hittable = visible && enabled && element. isHittable
885+ if options. compact && !hasContent && !hittable && !interactiveTypes. contains ( elementType) {
886+ return
887+ }
888+ if let scope = options. scope? . trimmingCharacters ( in: . whitespacesAndNewlines) , !scope. isEmpty {
889+ let haystack = [ label, identifier, valueText ?? " " ] . joined ( separator: " \n " )
890+ if !haystack. localizedCaseInsensitiveContains ( scope) {
891+ return
892+ }
893+ }
894+
895+ node = SnapshotNode (
896+ index: index,
897+ type: elementTypeName ( elementType) ,
898+ label: label. isEmpty ? nil : label,
899+ identifier: identifier. isEmpty ? nil : identifier,
900+ value: valueText,
901+ rect: snapshotRect ( from: frame) ,
902+ enabled: enabled,
903+ focused: elementHasFocus ( element) ? true : nil ,
904+ selected: element. isSelected ? true : nil ,
905+ hittable: hittable,
906+ depth: 1 ,
907+ parentIndex: parentIndex,
908+ hiddenContentAbove: nil ,
909+ hiddenContentBelow: nil
910+ )
911+ } )
912+ if let exceptionMessage {
913+ NSLog ( " AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_IGNORED_EXCEPTION=%@ " , exceptionMessage)
914+ return nil
915+ }
916+ return node
917+ }
918+
919+ private func nonEmptyElementText( _ read: ( ) -> String ) -> String ? {
920+ let value = safely ( " SNAPSHOT_FLAT_TEXT " , " " ) {
921+ read ( )
922+ } . trimmingCharacters ( in: . whitespacesAndNewlines)
923+ return value. isEmpty ? nil : value
924+ }
925+
717926 private func isScrollableContainer( _ snapshot: XCUIElementSnapshot , visible: Bool ) -> Bool {
718927 if !visible { return false }
719928 if !Self. scrollContainerTypes. contains ( snapshot. elementType) { return false }
0 commit comments