agent-device 0.16.14 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.apk → agent-device-android-multitouch-helper-0.17.1.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.1.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.manifest.json → agent-device-android-multitouch-helper-0.17.1.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.apk → agent-device-android-snapshot-helper-0.17.1.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.1.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.manifest.json → agent-device-android-snapshot-helper-0.17.1.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/221.js +4 -4
  9. package/dist/src/2415.js +29 -29
  10. package/dist/src/2805.js +1 -1
  11. package/dist/src/6232.js +1 -1
  12. package/dist/src/7599.js +4 -3
  13. package/dist/src/8020.js +1 -0
  14. package/dist/src/8699.js +1 -1
  15. package/dist/src/9238.js +3 -3
  16. package/dist/src/940.js +1 -1
  17. package/dist/src/9533.js +1 -1
  18. package/dist/src/9542.js +3 -3
  19. package/dist/src/android-snapshot-helper.d.ts +1 -0
  20. package/dist/src/apple.js +1 -1
  21. package/dist/src/apps.js +1 -1
  22. package/dist/src/args.js +15 -10
  23. package/dist/src/cli.js +9 -9
  24. package/dist/src/command-metadata.js +1 -1
  25. package/dist/src/contracts.d.ts +1 -0
  26. package/dist/src/find.js +1 -1
  27. package/dist/src/finders.d.ts +1 -0
  28. package/dist/src/generic.js +12 -10
  29. package/dist/src/index.d.ts +20 -1
  30. package/dist/src/interaction.js +1 -1
  31. package/dist/src/record-trace-recording.js +26 -0
  32. package/dist/src/record-trace.js +1 -26
  33. package/dist/src/selectors.d.ts +1 -0
  34. package/dist/src/session.js +11 -11
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +4 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +71 -0
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +41 -7
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +160 -13
  39. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +11 -0
  40. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +12 -4
  41. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +26 -0
  42. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -0
  43. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +7 -1
  44. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +571 -56
  45. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +21 -0
  46. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +11 -0
  47. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +13 -2
  48. package/ios-runner/README.md +13 -0
  49. package/package.json +2 -2
  50. package/server.json +2 -2
  51. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.14.apk.sha256 +0 -1
  52. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.14.apk.sha256 +0 -1
@@ -2,8 +2,15 @@ import XCTest
2
2
 
3
3
  extension RunnerTests {
4
4
  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"
5
8
  private static let axSnapshotHint =
6
- "XCTest could not serialize this iOS accessibility tree. Try a smaller read such as snapshot -s <visible label or id> -d 8, use direct selector commands such as find id <value> click, or use screenshot/logs/appstate in the same session. If you own the app and need full-tree inspection, consider flagging this screen for accessibility-tree simplification: reduce unnecessary accessible wrapper nesting and expose stable ids on actionable controls."
9
+ "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."
10
+ private static let rawSnapshotTooLargeCode = "IOS_RAW_SNAPSHOT_TOO_LARGE"
11
+ private static let rawSnapshotMaxNodes = 5_000
12
+ private static let rawSnapshotTooLargeHint =
13
+ "Raw iOS snapshot exceeded the runner payload guard. Use regular snapshot for visible UI, or scope/depth-limit raw snapshot when inspecting a large accessibility tree."
7
14
  private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
8
15
  .button,
9
16
  .link,
@@ -16,6 +23,7 @@ extension RunnerTests {
16
23
  .scrollView,
17
24
  .table
18
25
  ]
26
+ private static let flatInteractiveFallbackBudget: TimeInterval = 1.0
19
27
 
20
28
  private struct SnapshotTraversalContext {
21
29
  let queryRoot: XCUIElement
@@ -36,6 +44,12 @@ extension RunnerTests {
36
44
  let visible: Bool
37
45
  }
38
46
 
47
+ private enum SnapshotTraversalCapture {
48
+ case context(SnapshotTraversalContext)
49
+ case fallback(DataPayload)
50
+ case empty
51
+ }
52
+
39
53
  struct SnapshotCaptureFailure: Error {
40
54
  let code: String
41
55
  let message: String
@@ -85,11 +99,25 @@ extension RunnerTests {
85
99
  }
86
100
 
87
101
  func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
102
+ if options.interactiveOnly && options.compact {
103
+ return snapshotFlatInteractive(app: app, options: options)
104
+ }
88
105
  if let blocking = blockingSystemAlertSnapshot() {
89
106
  return blocking
90
107
  }
91
108
 
92
- guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
109
+ let capture = try captureSnapshotTraversalContext(
110
+ app: app,
111
+ options: options,
112
+ allowInteractiveUnavailableFallback: true
113
+ )
114
+ let context: SnapshotTraversalContext
115
+ switch capture {
116
+ case .context(let traversalContext):
117
+ context = traversalContext
118
+ case .fallback(let fallback):
119
+ return fallback
120
+ case .empty:
93
121
  return DataPayload(nodes: [], truncated: false)
94
122
  }
95
123
 
@@ -98,15 +126,15 @@ extension RunnerTests {
98
126
  if let cachedDescendantElements {
99
127
  return cachedDescendantElements
100
128
  }
101
- let fetched = safeSnapshotElementsQuery {
129
+ let result = snapshotElementsQuery {
102
130
  context.queryRoot.descendants(matching: .any).allElementsBoundByIndex
103
131
  }
104
- cachedDescendantElements = fetched
105
- return fetched
132
+ cachedDescendantElements = result.elements
133
+ return result.elements
106
134
  }
107
135
 
108
136
  var nodes: [SnapshotNode] = []
109
- var truncated = false
137
+ var hiddenContentHintsByNodeIndex: [Int: (above: Bool, below: Bool)] = [:]
110
138
  let rootEvaluation = evaluateSnapshot(context.rootSnapshot, in: context)
111
139
  nodes.append(
112
140
  makeSnapshotNode(
@@ -118,30 +146,42 @@ extension RunnerTests {
118
146
  )
119
147
  )
120
148
  if context.maxDepth > 0 {
121
- let didTruncateFallback = appendCollapsedTabFallbackNodes(
149
+ appendCollapsedTabFallbackNodes(
122
150
  to: &nodes,
123
151
  containerSnapshot: context.rootSnapshot,
124
152
  resolveElements: collapsedTabDescendants,
125
153
  depth: 1,
126
- parentIndex: 0,
127
- nodeLimit: fastSnapshotLimit
154
+ parentIndex: 0
128
155
  )
129
- truncated = truncated || didTruncateFallback
130
156
  }
131
157
 
132
158
  var seen = Set<String>()
133
- var stack: [(XCUIElementSnapshot, Int, Int, Int?)] = context.rootSnapshot.children.map {
134
- ($0, 1, 1, 0)
135
- }
136
-
137
- while let (snapshot, depth, visibleDepth, parentIndex) = stack.popLast() {
138
- if nodes.count >= fastSnapshotLimit {
139
- truncated = true
140
- break
159
+ let rootScrollAnchor = scrollContainerAnchor(
160
+ for: context.rootSnapshot,
161
+ visible: rootEvaluation.visible,
162
+ nodeIndex: 0
163
+ )
164
+ var stack: [(XCUIElementSnapshot, Int, Int, Int?, (index: Int, rect: CGRect)?)] =
165
+ context.rootSnapshot.children.map {
166
+ ($0, 1, 1, 0, rootScrollAnchor)
141
167
  }
168
+
169
+ while let (snapshot, depth, visibleDepth, parentIndex, nearestScrollAnchor) = stack.popLast() {
142
170
  if let limit = options.depth, depth > limit { continue }
143
171
 
144
172
  let evaluation = evaluateSnapshot(snapshot, in: context)
173
+ let regularVisible = isVisibleInRegularSnapshot(
174
+ snapshot.frame,
175
+ viewport: context.viewport,
176
+ scrollContainerAnchor: nearestScrollAnchor
177
+ )
178
+ if !regularVisible, let nearestScrollAnchor {
179
+ rememberHiddenContentHint(
180
+ for: snapshot.frame,
181
+ relativeTo: nearestScrollAnchor,
182
+ hints: &hiddenContentHintsByNodeIndex
183
+ )
184
+ }
145
185
  let include = shouldInclude(
146
186
  snapshot: snapshot,
147
187
  label: evaluation.label,
@@ -149,7 +189,8 @@ extension RunnerTests {
149
189
  valueText: evaluation.valueText,
150
190
  options: options,
151
191
  hittable: evaluation.hittable,
152
- visible: evaluation.visible
192
+ visible: regularVisible,
193
+ regularSnapshot: true
153
194
  )
154
195
 
155
196
  let key = "\(snapshot.elementType)-\(evaluation.label)-\(evaluation.identifier)-\(snapshot.frame.origin.x)-\(snapshot.frame.origin.y)"
@@ -161,8 +202,20 @@ extension RunnerTests {
161
202
  let currentIndex = include && !isDuplicate ? nodes.count : parentIndex
162
203
  if depth < context.maxDepth {
163
204
  let nextVisibleDepth = include && !isDuplicate ? visibleDepth + 1 : visibleDepth
205
+ let nextScrollContainerAnchor: (index: Int, rect: CGRect)?
206
+ if include && !isDuplicate {
207
+ nextScrollContainerAnchor =
208
+ scrollContainerAnchor(
209
+ for: snapshot,
210
+ visible: regularVisible,
211
+ nodeIndex: currentIndex
212
+ )
213
+ ?? nearestScrollAnchor
214
+ } else {
215
+ nextScrollContainerAnchor = nearestScrollAnchor
216
+ }
164
217
  for child in snapshot.children.reversed() {
165
- stack.append((child, depth + 1, nextVisibleDepth, currentIndex))
218
+ stack.append((child, depth + 1, nextVisibleDepth, currentIndex, nextScrollContainerAnchor))
166
219
  }
167
220
  }
168
221
 
@@ -179,20 +232,21 @@ extension RunnerTests {
179
232
  )
180
233
  )
181
234
  if visibleDepth < context.maxDepth {
182
- let didTruncateFallback = appendCollapsedTabFallbackNodes(
235
+ appendCollapsedTabFallbackNodes(
183
236
  to: &nodes,
184
237
  containerSnapshot: snapshot,
185
238
  resolveElements: collapsedTabDescendants,
186
239
  depth: visibleDepth + 1,
187
- parentIndex: index,
188
- nodeLimit: fastSnapshotLimit
240
+ parentIndex: index
189
241
  )
190
- truncated = truncated || didTruncateFallback
191
242
  }
192
243
 
193
244
  }
194
245
 
195
- return DataPayload(nodes: nodes, truncated: truncated)
246
+ return DataPayload(
247
+ nodes: applyHiddenContentHints(hiddenContentHintsByNodeIndex, to: nodes),
248
+ truncated: false
249
+ )
196
250
  }
197
251
 
198
252
  func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
@@ -200,18 +254,24 @@ extension RunnerTests {
200
254
  return blocking
201
255
  }
202
256
 
203
- guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
257
+ let capture = try captureSnapshotTraversalContext(
258
+ app: app,
259
+ options: options,
260
+ allowInteractiveUnavailableFallback: false
261
+ )
262
+ let context: SnapshotTraversalContext
263
+ switch capture {
264
+ case .context(let traversalContext):
265
+ context = traversalContext
266
+ case .fallback(let fallback):
267
+ return fallback
268
+ case .empty:
204
269
  return DataPayload(nodes: [], truncated: false)
205
270
  }
206
271
 
207
272
  var nodes: [SnapshotNode] = []
208
- var truncated = false
209
273
 
210
- func walk(_ snapshot: XCUIElementSnapshot, depth: Int, parentIndex: Int?) {
211
- if nodes.count >= maxSnapshotElements {
212
- truncated = true
213
- return
214
- }
274
+ func walk(_ snapshot: XCUIElementSnapshot, depth: Int, parentIndex: Int?) throws {
215
275
  if let limit = options.depth, depth > limit { return }
216
276
 
217
277
  let evaluation = evaluateSnapshot(snapshot, in: context)
@@ -226,6 +286,9 @@ extension RunnerTests {
226
286
  )
227
287
  let currentIndex = include ? nodes.count : parentIndex
228
288
  if include {
289
+ if nodes.count >= Self.rawSnapshotMaxNodes {
290
+ throw rawSnapshotTooLargeFailure(nodeCount: nodes.count + 1)
291
+ }
229
292
  nodes.append(
230
293
  makeSnapshotNode(
231
294
  snapshot: snapshot,
@@ -239,15 +302,286 @@ extension RunnerTests {
239
302
 
240
303
  let children = snapshot.children
241
304
  for child in children {
242
- walk(child, depth: depth + 1, parentIndex: currentIndex)
243
- if truncated { return }
305
+ try walk(child, depth: depth + 1, parentIndex: currentIndex)
244
306
  }
245
307
  }
246
308
 
247
- walk(context.rootSnapshot, depth: 0, parentIndex: nil)
309
+ try walk(context.rootSnapshot, depth: 0, parentIndex: nil)
310
+ return DataPayload(nodes: nodes, truncated: false)
311
+ }
312
+
313
+ private func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
314
+ var nodes: [SnapshotNode] = [
315
+ compactInteractiveRootNode(rect: .zero)
316
+ ]
317
+ if options.depth == 0 {
318
+ return DataPayload(nodes: nodes, truncated: false)
319
+ }
320
+
321
+ let deadline = options.interactiveOnly
322
+ ? Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
323
+ : Date.distantFuture
324
+ let viewport = safeSnapshotViewport(app: app)
325
+ var seen = Set<String>()
326
+ var candidates: [SnapshotNode] = []
327
+ let flatElements = flatInteractiveElements(app: app, deadline: deadline)
328
+ var truncated = flatElements.truncated
329
+ for element in flatElements.elements {
330
+ if Date() >= deadline {
331
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
332
+ truncated = true
333
+ break
334
+ }
335
+ guard let node = flatSnapshotNode(
336
+ element: element,
337
+ index: 0,
338
+ parentIndex: 0,
339
+ viewport: viewport,
340
+ options: options
341
+ ) else {
342
+ continue
343
+ }
344
+ let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
345
+ if seen.contains(key) { continue }
346
+ seen.insert(key)
347
+ candidates.append(node)
348
+ }
349
+ candidates.sort { left, right in
350
+ if left.rect.y != right.rect.y {
351
+ return left.rect.y < right.rect.y
352
+ }
353
+ if left.rect.x != right.rect.x {
354
+ return left.rect.x < right.rect.x
355
+ }
356
+ return left.type < right.type
357
+ }
358
+
359
+ nodes[0] = compactInteractiveRootNode(rect: compactInteractiveRootFrame(for: candidates))
360
+ for candidate in candidates {
361
+ nodes.append(
362
+ SnapshotNode(
363
+ index: nodes.count,
364
+ type: candidate.type,
365
+ label: candidate.label,
366
+ identifier: candidate.identifier,
367
+ value: candidate.value,
368
+ rect: candidate.rect,
369
+ enabled: candidate.enabled,
370
+ focused: candidate.focused,
371
+ selected: candidate.selected,
372
+ hittable: candidate.hittable,
373
+ depth: 1,
374
+ parentIndex: 0,
375
+ hiddenContentAbove: nil,
376
+ hiddenContentBelow: nil
377
+ )
378
+ )
379
+ }
248
380
  return DataPayload(nodes: nodes, truncated: truncated)
249
381
  }
250
382
 
383
+ private func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
384
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
385
+ invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
386
+ return sparseTruncatedSnapshotPayload(
387
+ message: recoveredSnapshotMessage(failure),
388
+ runnerFatal: true,
389
+ runnerFatalReason: Self.axSnapshotUnavailableReason
390
+ )
391
+ }
392
+
393
+ private func captureSnapshotTraversalContext(
394
+ app: XCUIApplication,
395
+ options: SnapshotOptions,
396
+ allowInteractiveUnavailableFallback: Bool
397
+ ) throws -> SnapshotTraversalCapture {
398
+ do {
399
+ guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
400
+ return .empty
401
+ }
402
+ return .context(context)
403
+ } catch let failure as SnapshotCaptureFailure {
404
+ if let fallback = snapshotDepthLimitedAccessibilityFallback(
405
+ app: app,
406
+ options: options,
407
+ failure: failure
408
+ ) {
409
+ return .fallback(fallback)
410
+ }
411
+ if allowInteractiveUnavailableFallback && options.interactiveOnly {
412
+ return .fallback(snapshotAccessibilityUnavailable(failure: failure))
413
+ }
414
+ throw failure
415
+ }
416
+ }
417
+
418
+ private func snapshotDepthLimitedAccessibilityFallback(
419
+ app: XCUIApplication,
420
+ options: SnapshotOptions,
421
+ failure: SnapshotCaptureFailure
422
+ ) -> DataPayload? {
423
+ guard let requestedDepth = options.depth else {
424
+ return nil
425
+ }
426
+
427
+ NSLog(
428
+ "AGENT_DEVICE_RUNNER_SNAPSHOT_DEPTH_FALLBACK=%@",
429
+ failure.message
430
+ )
431
+
432
+ if requestedDepth <= 0 {
433
+ return sparseTruncatedSnapshotPayload(message: recoveredSnapshotMessage(failure))
434
+ }
435
+
436
+ // Raw depth-limited recovery intentionally falls back to sparse interactive discovery because
437
+ // the raw AX tree is the failed operation.
438
+ let fallback = snapshotFlatInteractive(
439
+ app: app,
440
+ options: SnapshotOptions(
441
+ interactiveOnly: true,
442
+ compact: options.compact,
443
+ depth: requestedDepth,
444
+ scope: options.scope,
445
+ raw: false
446
+ )
447
+ )
448
+ return DataPayload(
449
+ message: recoveredSnapshotMessage(failure),
450
+ nodes: fallback.nodes,
451
+ truncated: true
452
+ )
453
+ }
454
+
455
+ private func recoveredSnapshotMessage(_ failure: SnapshotCaptureFailure) -> String {
456
+ return "\(failure.message) Hint: \(failure.hint)"
457
+ }
458
+
459
+ private func rawSnapshotTooLargeFailure(nodeCount: Int) -> SnapshotCaptureFailure {
460
+ SnapshotCaptureFailure(
461
+ code: Self.rawSnapshotTooLargeCode,
462
+ message: "iOS raw snapshot exceeded \(Self.rawSnapshotMaxNodes) nodes while walking node \(nodeCount).",
463
+ hint: Self.rawSnapshotTooLargeHint
464
+ )
465
+ }
466
+
467
+ private func sparseTruncatedSnapshotPayload(
468
+ message: String,
469
+ runnerFatal: Bool? = nil,
470
+ runnerFatalReason: String? = nil
471
+ ) -> DataPayload {
472
+ return DataPayload(
473
+ message: message,
474
+ nodes: [compactInteractiveRootNode(rect: .zero)],
475
+ truncated: true,
476
+ runnerFatal: runnerFatal,
477
+ runnerFatalReason: runnerFatalReason
478
+ )
479
+ }
480
+
481
+ func testSnapshotAccessibilityUnavailableMarksSparseSnapshotRunnerFatal() {
482
+ currentApp = app
483
+ currentBundleId = "com.example.app"
484
+
485
+ let payload = snapshotAccessibilityUnavailable(
486
+ failure: SnapshotCaptureFailure(
487
+ code: Self.axSnapshotErrorCode,
488
+ message: Self.axSnapshotFailureMessage,
489
+ hint: Self.axSnapshotHint
490
+ )
491
+ )
492
+
493
+ XCTAssertEqual(payload.message, "\(Self.axSnapshotFailureMessage) Hint: \(Self.axSnapshotHint)")
494
+ XCTAssertEqual(payload.nodes?.count, 1)
495
+ XCTAssertEqual(payload.nodes?.first?.type, "Application")
496
+ XCTAssertEqual(payload.truncated, true)
497
+ XCTAssertEqual(payload.runnerFatal, true)
498
+ XCTAssertEqual(payload.runnerFatalReason, Self.axSnapshotUnavailableReason)
499
+ XCTAssertNil(currentApp)
500
+ XCTAssertNil(currentBundleId)
501
+ }
502
+
503
+ func testRecoveredSnapshotMessagePreservesHint() {
504
+ let message = recoveredSnapshotMessage(
505
+ SnapshotCaptureFailure(
506
+ code: Self.axSnapshotErrorCode,
507
+ message: Self.axSnapshotFailureMessage,
508
+ hint: Self.axSnapshotHint
509
+ )
510
+ )
511
+
512
+ XCTAssertTrue(message.contains(Self.axSnapshotFailureMessage))
513
+ XCTAssertTrue(message.contains(Self.axSnapshotHint))
514
+ }
515
+
516
+ func testRawSnapshotTooLargeFailureIsStructured() {
517
+ let failure = rawSnapshotTooLargeFailure(nodeCount: Self.rawSnapshotMaxNodes + 1)
518
+
519
+ XCTAssertEqual(failure.code, Self.rawSnapshotTooLargeCode)
520
+ XCTAssertTrue(failure.message.contains("\(Self.rawSnapshotMaxNodes) nodes"))
521
+ XCTAssertEqual(failure.hint, Self.rawSnapshotTooLargeHint)
522
+ }
523
+
524
+ func testDepthLimitedSnapshotFailureReturnsNonFatalFallback() {
525
+ currentApp = app
526
+ currentBundleId = "com.example.app"
527
+
528
+ let payload = snapshotDepthLimitedAccessibilityFallback(
529
+ app: app,
530
+ options: SnapshotOptions(
531
+ interactiveOnly: false,
532
+ compact: false,
533
+ depth: 0,
534
+ scope: nil,
535
+ raw: false
536
+ ),
537
+ failure: SnapshotCaptureFailure(
538
+ code: Self.axSnapshotErrorCode,
539
+ message: "\(Self.axSnapshotFailureMessage) kAXErrorIllegalArgument.",
540
+ hint: Self.axSnapshotHint
541
+ )
542
+ )
543
+
544
+ XCTAssertEqual(
545
+ payload?.message,
546
+ "\(Self.axSnapshotFailureMessage) kAXErrorIllegalArgument. Hint: \(Self.axSnapshotHint)"
547
+ )
548
+ XCTAssertEqual(payload?.nodes?.count, 1)
549
+ XCTAssertEqual(payload?.nodes?.first?.type, "Application")
550
+ XCTAssertEqual(payload?.truncated, true)
551
+ XCTAssertNil(payload?.runnerFatal)
552
+ XCTAssertNil(payload?.runnerFatalReason)
553
+ XCTAssertNotNil(currentApp)
554
+ XCTAssertEqual(currentBundleId, "com.example.app")
555
+ }
556
+
557
+ private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode {
558
+ SnapshotNode(
559
+ index: 0,
560
+ type: "Application",
561
+ label: nil,
562
+ identifier: nil,
563
+ value: nil,
564
+ rect: snapshotRect(from: rect),
565
+ enabled: true,
566
+ focused: nil,
567
+ selected: nil,
568
+ hittable: false,
569
+ depth: 0,
570
+ parentIndex: nil,
571
+ hiddenContentAbove: nil,
572
+ hiddenContentBelow: nil
573
+ )
574
+ }
575
+
576
+ private func compactInteractiveRootFrame(for candidates: [SnapshotNode]) -> CGRect {
577
+ guard !candidates.isEmpty else {
578
+ return .zero
579
+ }
580
+ let maxX = candidates.map { CGFloat($0.rect.x + $0.rect.width) }.max() ?? 0
581
+ let maxY = candidates.map { CGFloat($0.rect.y + $0.rect.height) }.max() ?? 0
582
+ return CGRect(x: 0, y: 0, width: max(1, maxX), height: max(1, maxY))
583
+ }
584
+
251
585
  func snapshotRect(from frame: CGRect) -> SnapshotRect {
252
586
  return SnapshotRect(
253
587
  x: Double(frame.origin.x),
@@ -266,7 +600,8 @@ extension RunnerTests {
266
600
  valueText: String?,
267
601
  options: SnapshotOptions,
268
602
  hittable: Bool,
269
- visible: Bool
603
+ visible: Bool,
604
+ regularSnapshot: Bool = false
270
605
  ) -> Bool {
271
606
  let type = snapshot.elementType
272
607
  let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil)
@@ -288,6 +623,10 @@ extension RunnerTests {
288
623
  if options.compact {
289
624
  return hasContent || hittable
290
625
  }
626
+ if regularSnapshot {
627
+ if type == .application || type == .window { return true }
628
+ return visible
629
+ }
291
630
  return true
292
631
  }
293
632
 
@@ -366,11 +705,12 @@ extension RunnerTests {
366
705
  }
367
706
 
368
707
  private func axSnapshotFailure(_ message: String) -> SnapshotCaptureFailure {
708
+ let detail = message.trimmingCharacters(in: .whitespacesAndNewlines)
369
709
  let failureMessage: String
370
- if Self.hasAxIllegalArgumentCode(message) {
371
- failureMessage = "iOS XCTest snapshot failed with kAXErrorIllegalArgument. \(message)"
710
+ if detail.isEmpty {
711
+ failureMessage = Self.axSnapshotFailureMessage
372
712
  } else {
373
- failureMessage = "iOS XCTest snapshot failed while serializing the accessibility tree. \(message)"
713
+ failureMessage = "\(Self.axSnapshotFailureMessage) \(detail)"
374
714
  }
375
715
  return SnapshotCaptureFailure(
376
716
  code: Self.axSnapshotErrorCode,
@@ -381,14 +721,10 @@ extension RunnerTests {
381
721
 
382
722
  private static func isAxIllegalArgument(_ message: String) -> Bool {
383
723
  let normalized = message.lowercased()
384
- return hasAxIllegalArgumentCode(normalized)
724
+ return normalized.contains("kaxerrorillegalargument")
385
725
  || (normalized.contains("illegal argument") && normalized.contains("snapshot"))
386
726
  }
387
727
 
388
- private static func hasAxIllegalArgumentCode(_ message: String) -> Bool {
389
- return message.lowercased().contains("kaxerrorillegalargument")
390
- }
391
-
392
728
  private func evaluateSnapshot(
393
729
  _ snapshot: XCUIElementSnapshot,
394
730
  in context: SnapshotTraversalContext
@@ -491,8 +827,13 @@ extension RunnerTests {
491
827
 
492
828
  private func snapshotViewport(app: XCUIApplication) -> CGRect {
493
829
  let windows = app.windows.allElementsBoundByIndex
494
- if let window = windows.first(where: { $0.exists && !$0.frame.isNull && !$0.frame.isEmpty }) {
495
- return window.frame
830
+ let windowFrames = windows
831
+ .filter { $0.exists && !$0.frame.isNull && !$0.frame.isEmpty }
832
+ .map(\.frame)
833
+ if let largestWindowFrame = windowFrames.max(by: { left, right in
834
+ left.width * left.height < right.width * right.height
835
+ }) {
836
+ return largestWindowFrame
496
837
  }
497
838
  let appFrame = app.frame
498
839
  if !appFrame.isNull && !appFrame.isEmpty {
@@ -519,14 +860,23 @@ extension RunnerTests {
519
860
  return rect.intersects(viewport)
520
861
  }
521
862
 
863
+ private func isVisibleInRegularSnapshot(
864
+ _ rect: CGRect,
865
+ viewport: CGRect,
866
+ scrollContainerAnchor: (index: Int, rect: CGRect)?
867
+ ) -> Bool {
868
+ if !isVisibleInViewport(rect, viewport) { return false }
869
+ guard let scrollContainerAnchor else { return true }
870
+ return isVisibleInViewport(rect, scrollContainerAnchor.rect)
871
+ }
872
+
522
873
  private func appendCollapsedTabFallbackNodes(
523
874
  to nodes: inout [SnapshotNode],
524
875
  containerSnapshot: XCUIElementSnapshot,
525
876
  resolveElements: () -> [XCUIElement],
526
877
  depth: Int,
527
- parentIndex: Int,
528
- nodeLimit: Int
529
- ) -> Bool {
878
+ parentIndex: Int
879
+ ) {
530
880
  let fallbackNodes = collapsedTabFallbackNodes(
531
881
  for: containerSnapshot,
532
882
  resolveElements: resolveElements,
@@ -534,11 +884,62 @@ extension RunnerTests {
534
884
  depth: depth,
535
885
  parentIndex: parentIndex
536
886
  )
537
- if fallbackNodes.isEmpty { return false }
538
- let remaining = max(0, nodeLimit - nodes.count)
539
- if remaining == 0 { return true }
540
- nodes.append(contentsOf: fallbackNodes.prefix(remaining))
541
- return fallbackNodes.count > remaining
887
+ nodes.append(contentsOf: fallbackNodes)
888
+ }
889
+
890
+ private func scrollContainerAnchor(
891
+ for snapshot: XCUIElementSnapshot,
892
+ visible: Bool,
893
+ nodeIndex: Int?
894
+ ) -> (index: Int, rect: CGRect)? {
895
+ guard let nodeIndex else { return nil }
896
+ if !isScrollableContainer(snapshot, visible: visible) { return nil }
897
+ return (nodeIndex, snapshot.frame)
898
+ }
899
+
900
+ private func rememberHiddenContentHint(
901
+ for frame: CGRect,
902
+ relativeTo scrollContainerAnchor: (index: Int, rect: CGRect),
903
+ hints: inout [Int: (above: Bool, below: Bool)]
904
+ ) {
905
+ if frame.isNull || frame.isEmpty { return }
906
+ var hint = hints[scrollContainerAnchor.index] ?? (above: false, below: false)
907
+ if frame.maxY <= scrollContainerAnchor.rect.minY {
908
+ hint.above = true
909
+ } else if frame.minY >= scrollContainerAnchor.rect.maxY {
910
+ hint.below = true
911
+ } else {
912
+ return
913
+ }
914
+ hints[scrollContainerAnchor.index] = hint
915
+ }
916
+
917
+ private func applyHiddenContentHints(
918
+ _ hints: [Int: (above: Bool, below: Bool)],
919
+ to nodes: [SnapshotNode]
920
+ ) -> [SnapshotNode] {
921
+ if hints.isEmpty { return nodes }
922
+ return nodes.map { node in
923
+ guard let hint = hints[node.index] else { return node }
924
+ let hiddenContentAbove: Bool? = (node.hiddenContentAbove == true || hint.above) ? true : nil
925
+ let hiddenContentBelow: Bool? = (node.hiddenContentBelow == true || hint.below) ? true : nil
926
+ return SnapshotNode(
927
+ index: node.index,
928
+ type: node.type,
929
+ label: node.label,
930
+ identifier: node.identifier,
931
+ value: node.value,
932
+ rect: node.rect,
933
+ enabled: node.enabled,
934
+ focused: node.focused,
935
+ selected: node.selected,
936
+ hittable: node.hittable,
937
+ depth: node.depth,
938
+ parentIndex: node.parentIndex,
939
+ hiddenContentAbove: hiddenContentAbove,
940
+ hiddenContentBelow: hiddenContentBelow
941
+ )
942
+ }
542
943
  }
543
944
 
544
945
  private func collapsedTabFallbackNodes(
@@ -710,8 +1111,122 @@ extension RunnerTests {
710
1111
  return containerLabel == label && containerIdentifier == identifier
711
1112
  }
712
1113
 
713
- private func safeSnapshotElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
714
- safely("SNAPSHOT_QUERY", [], fetch)
1114
+ private func flatInteractiveElements(
1115
+ app: XCUIApplication,
1116
+ deadline: Date
1117
+ ) -> (elements: [XCUIElement], truncated: Bool) {
1118
+ let queries: [XCUIElementQuery] = [
1119
+ app.buttons,
1120
+ app.links,
1121
+ app.textFields,
1122
+ app.secureTextFields,
1123
+ app.searchFields,
1124
+ app.textViews,
1125
+ app.switches,
1126
+ app.sliders,
1127
+ app.segmentedControls,
1128
+ app.cells,
1129
+ app.collectionViews,
1130
+ app.tables,
1131
+ app.scrollViews,
1132
+ app.pickers,
1133
+ app.steppers,
1134
+ app.tabBars,
1135
+ app.menuItems,
1136
+ app.staticTexts,
1137
+ app.images
1138
+ ]
1139
+
1140
+ var elements: [XCUIElement] = []
1141
+ var truncated = false
1142
+ for query in queries {
1143
+ if Date() >= deadline {
1144
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
1145
+ truncated = true
1146
+ break
1147
+ }
1148
+ let result = snapshotElementsQuery {
1149
+ query.allElementsBoundByIndex
1150
+ }
1151
+ elements.append(contentsOf: result.elements)
1152
+ if result.axUnavailable {
1153
+ break
1154
+ }
1155
+ }
1156
+ return (elements, truncated)
1157
+ }
1158
+
1159
+ private func snapshotElementsQuery(
1160
+ _ fetch: () -> [XCUIElement]
1161
+ ) -> (elements: [XCUIElement], axUnavailable: Bool) {
1162
+ let (elements, exceptionMessage) = catchingObjCException(fallback: [], fetch)
1163
+ guard let exceptionMessage else {
1164
+ return (elements, false)
1165
+ }
1166
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_QUERY_IGNORED_EXCEPTION=%@", exceptionMessage)
1167
+ if Self.isAxIllegalArgument(exceptionMessage) {
1168
+ invalidateCachedTarget(reason: "ax_snapshot_query_unavailable")
1169
+ return ([], true)
1170
+ }
1171
+ return ([], false)
1172
+ }
1173
+
1174
+ private func flatSnapshotNode(
1175
+ element: XCUIElement,
1176
+ index: Int,
1177
+ parentIndex: Int?,
1178
+ viewport: CGRect,
1179
+ options: SnapshotOptions
1180
+ ) -> SnapshotNode? {
1181
+ var node: SnapshotNode?
1182
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1183
+ if !element.exists { return }
1184
+ let frame = element.frame
1185
+ if frame.isNull || frame.isEmpty { return }
1186
+ let visible = isVisibleInViewport(frame, viewport)
1187
+ if options.interactiveOnly && !visible { return }
1188
+ #if os(macOS)
1189
+ if !visible { return }
1190
+ #endif
1191
+ let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1192
+ let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
1193
+ let valueText = snapshotValueText(element)
1194
+ let hasContent = !label.isEmpty || !identifier.isEmpty || valueText != nil
1195
+ let elementType = element.elementType
1196
+ let enabled = element.isEnabled
1197
+ let hittable = visible && enabled && element.isHittable
1198
+ if options.compact && !hasContent && !hittable && !interactiveTypes.contains(elementType) {
1199
+ return
1200
+ }
1201
+ if let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines), !scope.isEmpty {
1202
+ let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
1203
+ if !haystack.localizedCaseInsensitiveContains(scope) {
1204
+ return
1205
+ }
1206
+ }
1207
+
1208
+ node = SnapshotNode(
1209
+ index: index,
1210
+ type: elementTypeName(elementType),
1211
+ label: label.isEmpty ? nil : label,
1212
+ identifier: identifier.isEmpty ? nil : identifier,
1213
+ value: valueText,
1214
+ rect: snapshotRect(from: frame),
1215
+ enabled: enabled,
1216
+ focused: elementHasFocus(element) ? true : nil,
1217
+ selected: element.isSelected ? true : nil,
1218
+ hittable: hittable,
1219
+ depth: 1,
1220
+ parentIndex: parentIndex,
1221
+ hiddenContentAbove: nil,
1222
+ hiddenContentBelow: nil
1223
+ )
1224
+ })
1225
+ if let exceptionMessage {
1226
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_IGNORED_EXCEPTION=%@", exceptionMessage)
1227
+ return nil
1228
+ }
1229
+ return node
715
1230
  }
716
1231
 
717
1232
  private func isScrollableContainer(_ snapshot: XCUIElementSnapshot, visible: Bool) -> Bool {