agent-device 0.18.3 → 0.19.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 (219) hide show
  1. package/README.md +5 -1
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.18.3.apk → agent-device-android-multitouch-helper-0.19.1.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.1.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.18.3.manifest.json → agent-device-android-multitouch-helper-0.19.1.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.18.3.apk → agent-device-android-snapshot-helper-0.19.1.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.1.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.18.3.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
  8. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTapPointPolicy.swift +28 -0
  9. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +33 -127
  10. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +630 -177
  11. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +155 -0
  12. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +67 -0
  13. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +173 -122
  14. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
  15. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +64 -0
  16. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -182
  17. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +92 -51
  18. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +213 -137
  19. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
  20. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +208 -46
  21. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +82 -11
  22. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +74 -0
  23. package/dist/src/agent-browser-tool.js +1 -0
  24. package/dist/src/android-adb.d.ts +63 -234
  25. package/dist/src/android-adb.js +1 -1
  26. package/dist/src/android-input-ownership.js +6 -0
  27. package/dist/src/android.js +1 -5
  28. package/dist/src/app-lifecycle.js +7 -0
  29. package/dist/src/apple-runner-options.js +1 -0
  30. package/dist/src/apps.js +5 -11
  31. package/dist/src/artifacts.d.ts +4 -3
  32. package/dist/src/artifacts.js +1 -1
  33. package/dist/src/audio-probe.js +1 -1
  34. package/dist/src/batch.d.ts +2 -177
  35. package/dist/src/batch.js +1 -1
  36. package/dist/src/cli-help.js +178 -60
  37. package/dist/src/cli-output.js +1 -1
  38. package/dist/src/cli-runner.js +2 -0
  39. package/dist/src/cli.js +12 -41
  40. package/dist/src/client-metro-companion.js +1 -0
  41. package/dist/src/client.js +3 -0
  42. package/dist/src/command-metadata.js +1 -0
  43. package/dist/src/command-schema.js +22 -0
  44. package/dist/src/command-surface.js +1 -1
  45. package/dist/src/contracts.d.ts +2 -213
  46. package/dist/src/contracts.js +1 -1
  47. package/dist/src/debug-symbols.js +5 -3
  48. package/dist/src/device-input-state.js +2 -0
  49. package/dist/src/devices.js +1 -1
  50. package/dist/src/devices2.js +1 -0
  51. package/dist/src/devices3.js +1 -0
  52. package/dist/src/find.js +1 -1
  53. package/dist/src/finders.d.ts +2 -81
  54. package/dist/src/finders.js +1 -1
  55. package/dist/src/generic.js +1 -1
  56. package/dist/src/handler-utils.js +1 -1
  57. package/dist/src/helper.js +2 -0
  58. package/dist/src/host-process.js +2 -0
  59. package/dist/src/index.d.ts +1444 -1786
  60. package/dist/src/index.js +1 -1
  61. package/dist/src/input-actions.js +3 -1
  62. package/dist/src/input-actions2.js +1 -0
  63. package/dist/src/install-source.d.ts +13 -14
  64. package/dist/src/install-source.js +1 -1
  65. package/dist/src/install-source2.js +1 -0
  66. package/dist/src/interaction-snapshot.js +1 -0
  67. package/dist/src/interaction.js +1 -1
  68. package/dist/src/interactor.js +2 -1
  69. package/dist/src/internal/bin.d.ts +1 -1
  70. package/dist/src/internal/bin.js +2 -5
  71. package/dist/src/internal/companion-tunnel.d.ts +1 -1
  72. package/dist/src/internal/companion-tunnel.js +1 -1
  73. package/dist/src/internal/daemon.d.ts +1 -1
  74. package/dist/src/internal/daemon.js +18 -1
  75. package/dist/src/internal/png-worker.d.ts +29 -26
  76. package/dist/src/internal/png-worker.js +1 -1
  77. package/dist/src/internal/update-check-entry.d.ts +1 -1
  78. package/dist/src/internal/update-check-entry.js +1 -1
  79. package/dist/src/io.d.ts +2 -85
  80. package/dist/src/io.js +1 -1
  81. package/dist/src/lease.js +1 -1
  82. package/dist/src/linux.js +1 -1
  83. package/dist/src/manifest.js +1 -0
  84. package/dist/src/metro.d.ts +69 -113
  85. package/dist/src/metro.js +1 -1
  86. package/dist/src/metro2.js +1 -0
  87. package/dist/src/notifications.js +1 -1
  88. package/dist/src/output-path.js +1 -0
  89. package/dist/src/path-resolution.js +1 -0
  90. package/dist/src/png-worker-contract.js +1 -0
  91. package/dist/src/points.js +4 -0
  92. package/dist/src/project-runtime.js +1 -0
  93. package/dist/src/provider.js +1 -1
  94. package/dist/src/providers.js +1 -0
  95. package/dist/src/react-native.js +1 -1
  96. package/dist/src/record-trace-recording.js +2 -5
  97. package/dist/src/record-trace.js +1 -1
  98. package/dist/src/recording-provider.js +1 -1
  99. package/dist/src/registry.js +38 -0
  100. package/dist/src/remote-config.d.ts +2 -83
  101. package/dist/src/reporting.js +9 -0
  102. package/dist/src/rolldown-runtime.js +1 -0
  103. package/dist/src/runner-client.js +1 -0
  104. package/dist/src/runner-provider.js +1 -0
  105. package/dist/src/runner-sequence.js +1 -1
  106. package/dist/src/runtime-transport.js +1 -0
  107. package/dist/src/runtime.js +3 -0
  108. package/dist/src/scoped-provider.js +1 -0
  109. package/dist/src/sdk-android-adb.d.ts +144 -0
  110. package/dist/src/sdk-android-adb.js +4 -0
  111. package/dist/src/sdk-app-inventory.d.ts +4 -0
  112. package/dist/src/sdk-app-inventory.js +1 -0
  113. package/dist/src/sdk-batch-runner.d.ts +39 -0
  114. package/dist/src/sdk-batch-runner.js +2 -0
  115. package/dist/src/sdk-batch.js +2 -0
  116. package/dist/src/sdk-contracts.d.ts +386 -0
  117. package/dist/src/sdk-contracts.js +2 -0
  118. package/dist/src/sdk-finders.d.ts +60 -0
  119. package/dist/src/sdk-finders.js +1 -0
  120. package/dist/src/sdk-io.d.ts +78 -0
  121. package/dist/src/sdk-io.js +1 -0
  122. package/dist/src/sdk-remote-config.d.ts +139 -0
  123. package/dist/src/sdk-remote-config.js +1 -0
  124. package/dist/src/sdk-selectors.js +1 -0
  125. package/dist/src/selector-runtime.js +1 -1
  126. package/dist/src/selectors.d.ts +38 -137
  127. package/dist/src/selectors.js +1 -1
  128. package/dist/src/selectors2.js +1 -0
  129. package/dist/src/server.js +3 -2
  130. package/dist/src/session.js +10 -11
  131. package/dist/src/setting-state.js +1 -0
  132. package/dist/src/simctl.js +1 -0
  133. package/dist/src/simulator.js +1 -2
  134. package/dist/src/snapshot-diagnostics.js +3 -0
  135. package/dist/src/snapshot-helper.js +5 -0
  136. package/dist/src/snapshot-quality.js +1 -0
  137. package/dist/src/snapshot.js +1 -2
  138. package/dist/src/snapshot2.js +2 -0
  139. package/dist/src/{5560.js → timeouts.js} +1 -1
  140. package/dist/src/tool-provider.js +3 -0
  141. package/dist/src/tool-provider2.js +1 -0
  142. package/dist/src/ttl-memo.js +1 -0
  143. package/dist/src/tv-remote.js +14 -0
  144. package/dist/src/update-check.js +1 -0
  145. package/dist/src/url.js +1 -0
  146. package/dist/src/version.js +1 -0
  147. package/dist/src/web.js +1 -1
  148. package/dist/src/xml.js +2 -0
  149. package/package.json +13 -19
  150. package/server.json +2 -2
  151. package/skills/agent-device/SKILL.md +8 -7
  152. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.18.3.apk.sha256 +0 -1
  153. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.18.3.apk.sha256 +0 -1
  154. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +0 -443
  155. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +0 -169
  156. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +0 -220
  157. package/dist/src/1404.js +0 -1
  158. package/dist/src/1986.js +0 -1
  159. package/dist/src/2748.js +0 -1
  160. package/dist/src/2948.js +0 -30
  161. package/dist/src/3006.js +0 -1
  162. package/dist/src/314.js +0 -1
  163. package/dist/src/3340.js +0 -8
  164. package/dist/src/4418.js +0 -1
  165. package/dist/src/4428.js +0 -3
  166. package/dist/src/485.js +0 -1
  167. package/dist/src/4917.js +0 -1
  168. package/dist/src/495.js +0 -5
  169. package/dist/src/5448.js +0 -3
  170. package/dist/src/5484.js +0 -1
  171. package/dist/src/5643.js +0 -1
  172. package/dist/src/587.js +0 -1
  173. package/dist/src/5913.js +0 -4
  174. package/dist/src/6849.js +0 -1
  175. package/dist/src/6918.js +0 -1
  176. package/dist/src/6967.js +0 -1
  177. package/dist/src/760.js +0 -1
  178. package/dist/src/7871.js +0 -1
  179. package/dist/src/8184.js +0 -1
  180. package/dist/src/8225.js +0 -1
  181. package/dist/src/8391.js +0 -1
  182. package/dist/src/8404.js +0 -2
  183. package/dist/src/8414.js +0 -2
  184. package/dist/src/8875.js +0 -4
  185. package/dist/src/893.js +0 -1
  186. package/dist/src/8936.js +0 -1
  187. package/dist/src/9414.js +0 -1
  188. package/dist/src/9581.js +0 -22
  189. package/dist/src/9612.js +0 -1
  190. package/dist/src/9624.js +0 -1
  191. package/dist/src/9722.js +0 -39
  192. package/dist/src/devices~1.js +0 -1
  193. package/dist/src/devices~2.js +0 -1
  194. package/dist/src/input-actions~1.js +0 -1
  195. package/dist/src/rslib-runtime.js +0 -1
  196. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +0 -0
  197. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +0 -0
  198. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -0
  199. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +0 -0
  200. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +0 -0
  201. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests.entitlements +0 -0
  202. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +0 -0
  203. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +0 -0
  204. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.h +0 -0
  205. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.m +0 -0
  206. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +0 -0
  207. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +0 -0
  208. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +0 -0
  209. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +0 -0
  210. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +0 -0
  211. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +0 -0
  212. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +0 -0
  213. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +0 -0
  214. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +0 -0
  215. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +0 -0
  216. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +0 -0
  217. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +0 -0
  218. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -0
  219. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-trim.swift +0 -0
@@ -99,7 +99,7 @@ extension RunnerTests {
99
99
  .table
100
100
  ]
101
101
 
102
- private static let flatInteractiveFallbackBudget: TimeInterval = 1.0
102
+ static let flatInteractiveFallbackBudget: TimeInterval = 1.0
103
103
 
104
104
  func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
105
105
  if let blocking = blockingSystemAlertSnapshot() {
@@ -302,7 +302,11 @@ extension RunnerTests {
302
302
  return DataPayload(nodes: nodes, truncated: false)
303
303
  }
304
304
 
305
- func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
305
+ func snapshotFlatInteractive(
306
+ app: XCUIApplication,
307
+ options: SnapshotOptions,
308
+ planDeadline: Date = .distantFuture
309
+ ) -> DataPayload {
306
310
  var nodes: [SnapshotNode] = [
307
311
  interactiveRootNode(rect: .zero)
308
312
  ]
@@ -310,9 +314,12 @@ extension RunnerTests {
310
314
  return DataPayload(nodes: nodes, truncated: false)
311
315
  }
312
316
 
313
- let deadline = options.interactiveOnly
317
+ // Bounded by both its own sweep budget and the umbrella capture-plan deadline, so a
318
+ // chained recovery tier can never push the plan past the main-thread watchdog (#1105).
319
+ let sweepDeadline = options.interactiveOnly
314
320
  ? Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
315
321
  : Date.distantFuture
322
+ let deadline = min(sweepDeadline, planDeadline)
316
323
  let viewport = safeSnapshotViewport(app: app)
317
324
  var seen = Set<String>()
318
325
  var candidates: [SnapshotNode] = []
@@ -380,6 +387,7 @@ extension RunnerTests {
380
387
 
381
388
  func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
382
389
  NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
390
+ runnerAccessibilityHealth = .unavailable
383
391
  invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
384
392
  // This is a planned terminal result, so it carries the structured verdict like every other
385
393
  // planned snapshot — downstream sparse handling keys off the verdict, not node shapes.
@@ -426,50 +434,6 @@ extension RunnerTests {
426
434
  )
427
435
  }
428
436
 
429
- #if AGENT_DEVICE_RUNNER_UNIT_TESTS
430
- func testSnapshotAccessibilityUnavailableMarksSparseSnapshotRunnerFatal() {
431
- currentApp = app
432
- currentBundleId = "com.example.app"
433
-
434
- let payload = snapshotAccessibilityUnavailable(
435
- failure: SnapshotCaptureFailure(
436
- code: Self.axSnapshotErrorCode,
437
- message: Self.axSnapshotFailureMessage,
438
- hint: Self.axSnapshotHint
439
- )
440
- )
441
-
442
- XCTAssertEqual(payload.message, "\(Self.axSnapshotFailureMessage) Hint: \(Self.axSnapshotHint)")
443
- XCTAssertEqual(payload.nodes?.count, 1)
444
- XCTAssertEqual(payload.nodes?.first?.type, "Application")
445
- XCTAssertEqual(payload.truncated, true)
446
- XCTAssertEqual(payload.runnerFatal, true)
447
- XCTAssertEqual(payload.runnerFatalReason, Self.axSnapshotUnavailableReason)
448
- XCTAssertNil(currentApp)
449
- XCTAssertNil(currentBundleId)
450
- }
451
-
452
- func testRecoveredSnapshotMessagePreservesHint() {
453
- let message = recoveredSnapshotMessage(
454
- SnapshotCaptureFailure(
455
- code: Self.axSnapshotErrorCode,
456
- message: Self.axSnapshotFailureMessage,
457
- hint: Self.axSnapshotHint
458
- )
459
- )
460
-
461
- XCTAssertTrue(message.contains(Self.axSnapshotFailureMessage))
462
- XCTAssertTrue(message.contains(Self.axSnapshotHint))
463
- }
464
-
465
- func testRawSnapshotTooLargeFailureIsStructured() {
466
- let failure = rawSnapshotTooLargeFailure(nodeCount: Self.rawSnapshotMaxNodes + 1)
467
-
468
- XCTAssertEqual(failure.code, Self.rawSnapshotTooLargeCode)
469
- XCTAssertTrue(failure.message.contains("\(Self.rawSnapshotMaxNodes) nodes"))
470
- XCTAssertEqual(failure.hint, Self.rawSnapshotTooLargeHint)
471
- }
472
- #endif
473
437
 
474
438
  private func interactiveRootNode(rect: CGRect) -> SnapshotNode {
475
439
  SnapshotNode(
@@ -562,12 +526,24 @@ extension RunnerTests {
562
526
 
563
527
  func makeSnapshotTraversalContext(
564
528
  app: XCUIApplication,
565
- options: SnapshotOptions
529
+ options: SnapshotOptions,
530
+ captureDeadline: Date = .distantFuture,
531
+ treeCaptureSliceBudgetOverride: TimeInterval? = nil
566
532
  ) throws -> SnapshotTraversalContext? {
567
- let viewport = safeSnapshotViewport(app: app)
568
- let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
533
+ let (viewport, queryRoot) = try runMainThreadWork(
534
+ command: nil,
535
+ timeout: min(1.0, max(0.1, captureDeadline.timeIntervalSinceNow)),
536
+ timeoutError: snapshotMainThreadTimeoutError("preparing tree snapshot")
537
+ ) {
538
+ (
539
+ self.safeSnapshotViewport(app: app),
540
+ options.scope.flatMap { self.findScopeElement(app: app, scope: $0) } ?? app
541
+ )
542
+ }
569
543
 
570
- guard let rootSnapshot = try captureSnapshotRoot(queryRoot) else {
544
+ let treeSliceBudget = treeCaptureSliceBudgetOverride ?? treeCaptureSliceBudget
545
+ let slice = min(treeSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
546
+ guard let rootSnapshot = try captureSnapshotRootBounded(queryRoot, sliceSeconds: slice) else {
571
547
  return nil
572
548
  }
573
549
 
@@ -582,6 +558,71 @@ extension RunnerTests {
582
558
  )
583
559
  }
584
560
 
561
+ static let xCTestSnapshotTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"
562
+
563
+ func hasAbandonedTreeCapture() -> Bool {
564
+ treeCaptureLock.lock()
565
+ defer { treeCaptureLock.unlock() }
566
+ return abandonedTreeCaptureCount > 0
567
+ }
568
+
569
+ /// Runs the blocking tree-snapshot XPC on the main thread bounded by `sliceSeconds`. On
570
+ /// timeout the XPC keeps running on main (it cannot be cancelled); the capture is marked
571
+ /// abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is penalized
572
+ /// for this bundle, and the plan moves to the platform's independent recovery tier when one
573
+ /// exists (#1105/#1122).
574
+ private func captureSnapshotRootBounded(
575
+ _ element: XCUIElement,
576
+ sliceSeconds: TimeInterval
577
+ ) throws -> XCUIElementSnapshot? {
578
+ if Thread.isMainThread {
579
+ return try captureSnapshotRoot(element)
580
+ }
581
+ return try runMainThreadWork(
582
+ command: nil,
583
+ timeout: sliceSeconds,
584
+ timeoutError: treeCaptureTimeoutError(sliceSeconds: sliceSeconds),
585
+ onAbandoned: {
586
+ self.treeCaptureLock.lock()
587
+ self.abandonedTreeCaptureCount += 1
588
+ self.treeCaptureLock.unlock()
589
+ NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds)
590
+ self.penalizeSnapshotXCTestChannel(
591
+ bundleId: self.currentBundleId,
592
+ reason: "tree_capture_slice_timeout"
593
+ )
594
+ },
595
+ onDrained: {
596
+ self.treeCaptureLock.lock()
597
+ self.abandonedTreeCaptureCount -= 1
598
+ self.treeCaptureLock.unlock()
599
+ NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_DRAINED")
600
+ }
601
+ ) {
602
+ try self.captureSnapshotRoot(element)
603
+ }
604
+ }
605
+
606
+ private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> () -> Error {
607
+ {
608
+ SnapshotCaptureFailure(
609
+ code: Self.xCTestSnapshotTimeoutCode,
610
+ message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice",
611
+ hint: "The capture plan will avoid or tightly bound XCTest-backed snapshot tiers on this screen."
612
+ )
613
+ }
614
+ }
615
+
616
+ func snapshotMainThreadTimeoutError(_ operation: String) -> () -> Error {
617
+ {
618
+ SnapshotCaptureFailure(
619
+ code: Self.xCTestSnapshotTimeoutCode,
620
+ message: "timed out while \(operation) on the XCTest main thread",
621
+ hint: "The capture plan will skip XCTest-backed snapshot tiers while the previous main-thread work drains."
622
+ )
623
+ }
624
+ }
625
+
585
626
  private func captureSnapshotRoot(_ element: XCUIElement) throws -> XCUIElementSnapshot? {
586
627
  var rootSnapshot: XCUIElementSnapshot?
587
628
  var swiftErrorMessage: String?
@@ -28,6 +28,40 @@ enum SnapshotBackendKind: String, CaseIterable {
28
28
  case recursiveTree = "tree"
29
29
  case querySweep = "queries"
30
30
  case privateAX = "private-ax"
31
+
32
+ var usesXCTestAccessibilityChannel: Bool {
33
+ switch self {
34
+ case .recursiveTree, .querySweep:
35
+ return true
36
+ case .privateAX:
37
+ return false
38
+ }
39
+ }
40
+
41
+ var isAvailableOnCurrentPlatform: Bool {
42
+ switch self {
43
+ case .recursiveTree, .querySweep:
44
+ return true
45
+ case .privateAX:
46
+ #if os(iOS) && targetEnvironment(simulator)
47
+ return true
48
+ #else
49
+ return false
50
+ #endif
51
+ }
52
+ }
53
+ }
54
+
55
+ enum SnapshotXCTestChannelPlanState: Equatable {
56
+ case normal
57
+ case deferredToIndependentBackend
58
+ case boundedXCTestProbe
59
+ }
60
+
61
+ struct EffectiveSnapshotCapturePlan {
62
+ let plan: [SnapshotBackendKind]
63
+ let xCTestChannelState: SnapshotXCTestChannelPlanState
64
+ let treeCaptureSliceBudgetOverride: TimeInterval?
31
65
  }
32
66
 
33
67
  /// What the plan runner does when every backend failed or stayed sparse.
@@ -52,6 +86,7 @@ extension RunnerTests {
52
86
  /// but chained recovery tiers must never stack past the 30s main-thread watchdog: when the
53
87
  /// budget is spent, remaining tiers are skipped and the best payload so far is returned.
54
88
  static let snapshotPlanBudget: TimeInterval = 20
89
+ static let penalizedXCTestProbeTreeSliceBudget: TimeInterval = 1
55
90
  static let collapsedLeafMinimumSegments = 10
56
91
 
57
92
  static func payloadNodeCount(_ payload: DataPayload?) -> Int {
@@ -63,6 +98,70 @@ extension RunnerTests {
63
98
  static let regularVisiblePlan: [SnapshotBackendKind] = [.recursiveTree, .querySweep, .privateAX]
64
99
  static let rawDiagnosticPlan: [SnapshotBackendKind] = [.recursiveTree, .privateAX]
65
100
 
101
+ // MARK: XCTest accessibility channel penalty (cross-attempt memory, #1105/#1156)
102
+ //
103
+ // On some deep/dynamic screens the XCTest bulk snapshot no longer fails fast with
104
+ // kAXErrorIllegalArgument (the #758 signature) — it grinds for many seconds first. One slow
105
+ // grind is tolerable; re-grinding on every subsequent capture of the same screen buries the
106
+ // main thread past the execution watchdog. After a slow, timed-out, or abandoned XCTest-backed
107
+ // capture, later plans for the same bundle use non-XCTest recovery tiers until the penalty expires.
108
+
109
+ func penalizeSnapshotXCTestChannel(bundleId: String?, reason: String) {
110
+ snapshotXCTestChannelPenaltyLock.lock()
111
+ snapshotXCTestChannelPenaltyBundleId = bundleId
112
+ snapshotXCTestChannelPenaltyUntil = Date().addingTimeInterval(snapshotXCTestChannelPenaltyDuration)
113
+ snapshotXCTestChannelPenaltyLock.unlock()
114
+ NSLog(
115
+ "AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=%@ reason=%@",
116
+ bundleId ?? "",
117
+ reason
118
+ )
119
+ }
120
+
121
+ func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool {
122
+ snapshotXCTestChannelPenaltyLock.lock()
123
+ defer { snapshotXCTestChannelPenaltyLock.unlock() }
124
+ guard Date() < snapshotXCTestChannelPenaltyUntil else { return false }
125
+ // A penalty recorded without a bundle id applies to whatever target is current.
126
+ guard let penalized = snapshotXCTestChannelPenaltyBundleId else { return true }
127
+ return penalized == bundleId
128
+ }
129
+
130
+ /// Pure plan-reorder rule: a penalized XCTest accessibility channel uses independent backends
131
+ /// when the platform has one, otherwise it keeps XCTest work on a short probe. The raw
132
+ /// diagnostic plan keeps tree-first errors, and unknown plans are left untouched.
133
+ static func effectiveSnapshotCapturePlan(
134
+ _ plan: [SnapshotBackendKind],
135
+ xCTestChannelPenalized: Bool,
136
+ availableBackends: Set<SnapshotBackendKind> = Set(SnapshotBackendKind.allCases)
137
+ ) -> EffectiveSnapshotCapturePlan {
138
+ guard xCTestChannelPenalized, plan == Self.regularVisiblePlan else {
139
+ return EffectiveSnapshotCapturePlan(
140
+ plan: plan,
141
+ xCTestChannelState: .normal,
142
+ treeCaptureSliceBudgetOverride: nil
143
+ )
144
+ }
145
+ let availablePlan = plan.filter { availableBackends.contains($0) }
146
+ let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel }
147
+ if !recoveryPlan.isEmpty {
148
+ return EffectiveSnapshotCapturePlan(
149
+ plan: recoveryPlan,
150
+ xCTestChannelState: .deferredToIndependentBackend,
151
+ treeCaptureSliceBudgetOverride: nil
152
+ )
153
+ }
154
+ return EffectiveSnapshotCapturePlan(
155
+ plan: availablePlan.filter(\.usesXCTestAccessibilityChannel),
156
+ xCTestChannelState: .boundedXCTestProbe,
157
+ treeCaptureSliceBudgetOverride: Self.penalizedXCTestProbeTreeSliceBudget
158
+ )
159
+ }
160
+
161
+ func shouldSkipSnapshotBackendForAbandonedTreeCapture(_ kind: SnapshotBackendKind) -> Bool {
162
+ kind.usesXCTestAccessibilityChannel && hasAbandonedTreeCapture()
163
+ }
164
+
66
165
  // MARK: Plan runner
67
166
 
68
167
  func runSnapshotCapturePlan(
@@ -76,21 +175,76 @@ extension RunnerTests {
76
175
  var axFailure: SnapshotCaptureFailure?
77
176
  let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
78
177
 
79
- for kind in plan {
80
- if kind != plan.first && Date() >= deadline {
178
+ // Reorder is iOS-only because hostile screens can make XCTest tree/query work grind while
179
+ // the app remains visually responsive. Simulators can avoid that channel through private AX;
180
+ // physical devices have no independent semantic backend yet, so they use a bounded probe.
181
+ var xCTestChannelPenalized = false
182
+ #if os(iOS)
183
+ xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)
184
+ #endif
185
+ let effective = Self.effectiveSnapshotCapturePlan(
186
+ plan,
187
+ xCTestChannelPenalized: xCTestChannelPenalized,
188
+ availableBackends: Set(SnapshotBackendKind.allCases.filter(\.isAvailableOnCurrentPlatform))
189
+ )
190
+ let effectivePlan = effective.plan
191
+ switch effective.xCTestChannelState {
192
+ case .normal:
193
+ break
194
+ case .deferredToIndependentBackend:
195
+ firstFailure = (
196
+ "XCTest-backed snapshot tiers were deferred after recent slow accessibility work on this screen",
197
+ "budget"
198
+ )
199
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_DEFERRED bundle=%@", currentBundleId ?? "")
200
+ case .boundedXCTestProbe:
201
+ firstFailure = (
202
+ "XCTest-backed snapshot tiers are running with a short recovery probe after recent slow accessibility work on this screen",
203
+ "budget"
204
+ )
205
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PROBE_BOUNDED bundle=%@", currentBundleId ?? "")
206
+ }
207
+
208
+ for kind in effectivePlan {
209
+ if kind != effectivePlan.first && Date() >= deadline {
81
210
  NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_PLAN_BUDGET_EXHAUSTED skipped=%@", kind.rawValue)
82
211
  if firstFailure == nil {
83
212
  firstFailure = ("the capture plan ran out of its time budget", "budget")
84
213
  }
85
214
  break
86
215
  }
216
+ // While an abandoned tree capture is still grinding inside testmanagerd, XCTest-backed
217
+ // tiers would block behind it; only independent backends stay responsive (#1105).
218
+ if shouldSkipSnapshotBackendForAbandonedTreeCapture(kind) {
219
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue)
220
+ if firstFailure == nil {
221
+ firstFailure = (
222
+ "the XCTest capture channel is occupied by an abandoned tree capture",
223
+ "budget"
224
+ )
225
+ }
226
+ continue
227
+ }
87
228
  let capture: SnapshotBackendCapture
229
+ let backendStartedAt = Date()
88
230
  do {
89
- guard let result = try captureWithBackend(kind, app: app, options: options) else {
231
+ guard
232
+ let result = try captureWithBackend(
233
+ kind,
234
+ app: app,
235
+ options: options,
236
+ deadline: deadline,
237
+ treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride
238
+ )
239
+ else {
240
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
90
241
  continue
91
242
  }
92
243
  capture = result
244
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
93
245
  } catch let failure as SnapshotCaptureFailure {
246
+ recordXCTestSnapshotBackendFailureIfNeeded(kind, failure: failure)
247
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
94
248
  if Self.isAxSnapshotFailure(failure) { axFailure = failure }
95
249
  if firstFailure == nil {
96
250
  firstFailure = (failure.message, Self.isAxSnapshotFailure(failure) ? "ax-rejected" : "capture-failed")
@@ -111,7 +265,7 @@ extension RunnerTests {
111
265
  continue
112
266
  }
113
267
 
114
- let recovered = kind != plan.first
268
+ let recovered = kind != effectivePlan.first || effective.xCTestChannelState != .normal
115
269
  if recovered {
116
270
  NSLog(
117
271
  "AGENT_DEVICE_RUNNER_SNAPSHOT_RECOVERED backend=%@ reason=%@",
@@ -150,34 +304,76 @@ extension RunnerTests {
150
304
  best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: "sparse", reason: firstFailure) }
151
305
  ?? stampedSnapshotPayload(
152
306
  SnapshotBackendCapture(payload: sparseTruncatedSnapshotPayload(), effectiveDepth: nil),
153
- backend: plan.last ?? .recursiveTree,
307
+ backend: effectivePlan.last ?? plan.last ?? .recursiveTree,
154
308
  state: "sparse",
155
309
  reason: firstFailure
156
310
  )
157
311
  return fallbackPayload
158
312
  }
159
313
 
314
+ /// Marks XCTest-backed snapshot tiers as penalized when one attempt ground past the slow-capture
315
+ /// threshold — even a successful one: the next capture of this screen must not re-grind.
316
+ private func recordSlowXCTestSnapshotBackendIfNeeded(_ kind: SnapshotBackendKind, startedAt: Date) {
317
+ guard kind.usesXCTestAccessibilityChannel else { return }
318
+ let elapsed = Date().timeIntervalSince(startedAt)
319
+ guard elapsed > snapshotXCTestSlowCaptureThreshold else { return }
320
+ penalizeSnapshotXCTestChannel(
321
+ bundleId: currentBundleId,
322
+ reason: "slow_\(kind.rawValue)_capture_\(Int(elapsed * 1000))ms"
323
+ )
324
+ }
325
+
326
+ private func recordXCTestSnapshotBackendFailureIfNeeded(
327
+ _ kind: SnapshotBackendKind,
328
+ failure: SnapshotCaptureFailure
329
+ ) {
330
+ guard kind.usesXCTestAccessibilityChannel, failure.code == Self.xCTestSnapshotTimeoutCode else { return }
331
+ penalizeSnapshotXCTestChannel(
332
+ bundleId: currentBundleId,
333
+ reason: "\(kind.rawValue)_backend_timeout"
334
+ )
335
+ }
336
+
160
337
  private func captureWithBackend(
161
338
  _ kind: SnapshotBackendKind,
162
339
  app: XCUIApplication,
163
- options: SnapshotOptions
340
+ options: SnapshotOptions,
341
+ deadline: Date,
342
+ treeCaptureSliceBudgetOverride: TimeInterval?
164
343
  ) throws -> SnapshotBackendCapture? {
165
344
  switch kind {
166
345
  case .recursiveTree:
167
- guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
346
+ guard
347
+ let context = try makeSnapshotTraversalContext(
348
+ app: app,
349
+ options: options,
350
+ captureDeadline: deadline,
351
+ treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride
352
+ )
353
+ else {
168
354
  return nil
169
355
  }
170
- let payload = options.raw
171
- ? try rawTreeSnapshotPayload(context: context, options: options)
172
- : recursiveTreeSnapshotPayload(context: context, options: options)
356
+ let payload = try runMainThreadWork(
357
+ command: nil,
358
+ timeout: min(treeCaptureSliceBudget, max(0.5, deadline.timeIntervalSinceNow)),
359
+ timeoutError: snapshotMainThreadTimeoutError("processing tree snapshot")
360
+ ) {
361
+ options.raw
362
+ ? try self.rawTreeSnapshotPayload(context: context, options: options)
363
+ : self.recursiveTreeSnapshotPayload(context: context, options: options)
364
+ }
173
365
  return SnapshotBackendCapture(payload: payload, effectiveDepth: nil)
174
366
  case .querySweep:
175
- return SnapshotBackendCapture(
176
- payload: snapshotFlatInteractive(app: app, options: options),
177
- effectiveDepth: nil
178
- )
367
+ let payload = try runMainThreadWork(
368
+ command: nil,
369
+ timeout: min(Self.flatInteractiveFallbackBudget, max(0.1, deadline.timeIntervalSinceNow)),
370
+ timeoutError: snapshotMainThreadTimeoutError("running query-sweep snapshot")
371
+ ) {
372
+ self.snapshotFlatInteractive(app: app, options: options, planDeadline: deadline)
373
+ }
374
+ return SnapshotBackendCapture(payload: payload, effectiveDepth: nil)
179
375
  case .privateAX:
180
- return privateAXSnapshotCapture(app: app, options: options)
376
+ return privateAXSnapshotCapture(app: app, options: options, deadline: deadline)
181
377
  }
182
378
  }
183
379
 
@@ -255,6 +451,7 @@ extension RunnerTests {
255
451
  state: String,
256
452
  reason: (reason: String, code: String)?
257
453
  ) -> DataPayload {
454
+ runnerAccessibilityHealth = reason?.code == "ax-rejected" ? .unavailable : .healthy
258
455
  let payload = capture.payload
259
456
  let quality = SnapshotQuality(
260
457
  state: state,
@@ -283,7 +480,7 @@ extension RunnerTests {
283
480
  ? " The primary capture ran out of its time budget (busy app or simulator); the recovered tree is authoritative for this screen."
284
481
  : " This usually means the app publishes an unhealthy accessibility tree — fixing the app's accessibility is the real cure. Treat screenshot as visual truth when this warning appears."
285
482
  parts.append(
286
- "Recovered this snapshot with the \(quality.backend) accessibility backend"
483
+ "Detected an overly complex or slow accessibility tree. Fell back to the \(quality.backend) snapshot backend"
287
484
  + (quality.reason.map { " after: \($0)." } ?? ".")
288
485
  + meaning
289
486
  )
@@ -304,124 +501,3 @@ extension RunnerTests {
304
501
  }
305
502
  }
306
503
 
307
- #if AGENT_DEVICE_RUNNER_UNIT_TESTS
308
- // MARK: - In-bundle unit tests
309
-
310
- extension RunnerTests {
311
- private func planTestNode(
312
- index: Int,
313
- type: String,
314
- label: String? = nil,
315
- identifier: String? = nil,
316
- hittable: Bool = false,
317
- parentIndex: Int? = nil
318
- ) -> SnapshotNode {
319
- SnapshotNode(
320
- index: index,
321
- type: type,
322
- label: label,
323
- identifier: identifier,
324
- value: nil,
325
- rect: snapshotRect(from: .zero),
326
- enabled: true,
327
- focused: nil,
328
- selected: nil,
329
- hittable: hittable,
330
- depth: parentIndex == nil ? 0 : 1,
331
- parentIndex: parentIndex,
332
- hiddenContentAbove: nil,
333
- hiddenContentBelow: nil
334
- )
335
- }
336
-
337
- func testSparsePayloadReasonMatrix() {
338
- let root = planTestNode(index: 0, type: "Application", label: "Example App", hittable: true)
339
- let window = planTestNode(index: 1, type: "Window", parentIndex: 0)
340
- let button = planTestNode(index: 1, type: "Button", label: "Ok", hittable: true, parentIndex: 0)
341
-
342
- // Labeled, hittable root over a bare window is still sparse.
343
- XCTAssertNotNil(Self.sparsePayloadReason(DataPayload(nodes: [root, window], truncated: false)))
344
- // Deadline-truncated near-empty sweep needs recovery even with one real control.
345
- XCTAssertNotNil(Self.sparsePayloadReason(DataPayload(nodes: [root, button], truncated: true)))
346
- // The same tiny tree from a completed sweep is a legitimately minimal screen.
347
- XCTAssertNil(Self.sparsePayloadReason(DataPayload(nodes: [root, button], truncated: false)))
348
- // Empty payloads are degraded.
349
- XCTAssertNotNil(Self.sparsePayloadReason(DataPayload(nodes: [], truncated: false)))
350
- }
351
-
352
- func testCollapsedLeafIndexesFlagsMergedContainersOnly() {
353
- let root = planTestNode(index: 0, type: "Application", label: "App")
354
- let merged = planTestNode(
355
- index: 1,
356
- type: "Other",
357
- label: (0...30).map { "Row \($0), Tap" }.joined(separator: ", "),
358
- parentIndex: 0
359
- )
360
- let prose = planTestNode(
361
- index: 2,
362
- type: "StaticText",
363
- label: (0...30).map { "clause \($0)" }.joined(separator: ", "),
364
- parentIndex: 0
365
- )
366
- XCTAssertEqual(Self.collapsedLeafIndexes([root, merged, prose]), [1])
367
- XCTAssertNil(Self.collapsedLeafIndexes([root, prose]))
368
- }
369
-
370
- func testLegacyQualityMessageStatesFallbackMeaning() {
371
- let recovered = SnapshotQuality(
372
- state: "recovered",
373
- backend: "queries",
374
- reason: "snapshot returned only structural application/window nodes",
375
- reasonCode: "sparse-tree",
376
- effectiveDepth: nil,
377
- collapsedLeafIndexes: nil
378
- )
379
- let message = Self.legacyQualityMessage(recovered)
380
- XCTAssertTrue(message?.contains("queries accessibility backend") == true)
381
- XCTAssertTrue(message?.contains("fixing the app's accessibility") == true)
382
- XCTAssertTrue(message?.contains("screenshot as visual truth") == true)
383
- XCTAssertNil(
384
- Self.legacyQualityMessage(
385
- SnapshotQuality(
386
- state: "healthy", backend: "tree", reason: nil, reasonCode: nil, effectiveDepth: nil,
387
- collapsedLeafIndexes: nil)
388
- )
389
- )
390
- }
391
- func testTerminalFailsClosedOnInteractiveAxFailureRegardlessOfSparseBest() {
392
- // Interactive AX failure must invalidate + fail closed; a later tier's sparse synthetic-root
393
- // "best" must never downgrade this to a returned-sparse payload (regression: best == nil guard).
394
- XCTAssertEqual(
395
- Self.resolveSnapshotPlanTerminal(terminal: .sparseWithFatalOnAXFailure, interactiveOnly: true),
396
- .failClosed
397
- )
398
- XCTAssertEqual(
399
- Self.resolveSnapshotPlanTerminal(terminal: .sparseWithFatalOnAXFailure, interactiveOnly: false),
400
- .sparseBest
401
- )
402
- XCTAssertEqual(
403
- Self.resolveSnapshotPlanTerminal(terminal: .throwOnAXFailure, interactiveOnly: true),
404
- .throwAxFailure
405
- )
406
- }
407
-
408
- func testSnapshotAccessibilityUnavailableCarriesSparseVerdict() {
409
- currentApp = app
410
- currentBundleId = "com.example.app"
411
- defer {
412
- currentApp = nil
413
- currentBundleId = nil
414
- }
415
- let payload = snapshotAccessibilityUnavailable(
416
- failure: SnapshotCaptureFailure(
417
- code: "IOS_AX_SNAPSHOT_FAILED",
418
- message: "kAXErrorIllegalArgument",
419
- hint: "use screenshot"
420
- )
421
- )
422
- XCTAssertEqual(payload.runnerFatal, true)
423
- XCTAssertEqual(payload.snapshotQuality?.state, "sparse")
424
- XCTAssertEqual(payload.snapshotQuality?.reasonCode, "ax-rejected")
425
- }
426
- }
427
- #endif