agent-device 0.19.0 → 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 (84) hide show
  1. package/README.md +5 -1
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.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.19.0.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.19.0.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.19.0.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
  8. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +24 -7
  9. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +506 -119
  10. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +153 -48
  11. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
  12. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -5
  13. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +60 -52
  14. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +159 -56
  15. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
  16. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -14
  17. package/dist/src/android-adb.d.ts +1 -2
  18. package/dist/src/android-adb.js +1 -1
  19. package/dist/src/android-input-ownership.js +6 -0
  20. package/dist/src/android.js +1 -1
  21. package/dist/src/app-lifecycle.js +3 -3
  22. package/dist/src/apple-runner-options.js +1 -1
  23. package/dist/src/apps.js +5 -5
  24. package/dist/src/cli-help.js +131 -34
  25. package/dist/src/cli-runner.js +1 -1
  26. package/dist/src/cli.js +11 -11
  27. package/dist/src/client.js +2 -2
  28. package/dist/src/command-metadata.js +1 -1
  29. package/dist/src/command-schema.js +3 -3
  30. package/dist/src/device-input-state.js +2 -2
  31. package/dist/src/devices2.js +1 -1
  32. package/dist/src/find.js +1 -1
  33. package/dist/src/generic.js +1 -1
  34. package/dist/src/index.d.ts +199 -20
  35. package/dist/src/input-actions.js +3 -3
  36. package/dist/src/interaction-snapshot.js +1 -1
  37. package/dist/src/interaction.js +1 -1
  38. package/dist/src/interactor.js +2 -2
  39. package/dist/src/internal/bin.js +1 -1
  40. package/dist/src/internal/daemon.js +17 -15
  41. package/dist/src/lease.js +1 -1
  42. package/dist/src/linux.js +1 -1
  43. package/dist/src/metro.d.ts +0 -1
  44. package/dist/src/notifications.js +1 -1
  45. package/dist/src/points.js +1 -1
  46. package/dist/src/project-runtime.js +1 -1
  47. package/dist/src/providers.js +1 -1
  48. package/dist/src/react-native.js +1 -1
  49. package/dist/src/record-trace-recording.js +2 -2
  50. package/dist/src/record-trace.js +1 -1
  51. package/dist/src/registry.js +24 -23
  52. package/dist/src/reporting.js +3 -3
  53. package/dist/src/runner-client.js +1 -1
  54. package/dist/src/runtime.js +2 -2
  55. package/dist/src/sdk-android-adb.d.ts +2 -2
  56. package/dist/src/sdk-batch-runner.d.ts +0 -1
  57. package/dist/src/sdk-batch.js +2 -2
  58. package/dist/src/sdk-finders.d.ts +1 -2
  59. package/dist/src/sdk-finders.js +1 -1
  60. package/dist/src/sdk-io.d.ts +0 -1
  61. package/dist/src/sdk-remote-config.d.ts +1 -2
  62. package/dist/src/sdk-remote-config.js +1 -1
  63. package/dist/src/sdk-selectors.js +1 -1
  64. package/dist/src/selector-runtime.js +1 -1
  65. package/dist/src/selectors.d.ts +0 -1
  66. package/dist/src/selectors.js +1 -1
  67. package/dist/src/selectors2.js +1 -1
  68. package/dist/src/server.js +3 -3
  69. package/dist/src/session.js +10 -10
  70. package/dist/src/simctl.js +1 -1
  71. package/dist/src/simulator.js +1 -1
  72. package/dist/src/snapshot-diagnostics.js +2 -2
  73. package/dist/src/snapshot-helper.js +4 -4
  74. package/dist/src/snapshot-quality.js +1 -1
  75. package/dist/src/snapshot2.js +2 -2
  76. package/dist/src/tv-remote.js +14 -0
  77. package/dist/src/web.js +1 -1
  78. package/package.json +5 -4
  79. package/server.json +2 -2
  80. package/skills/agent-device/SKILL.md +8 -7
  81. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.0.apk.sha256 +0 -1
  82. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.0.apk.sha256 +0 -1
  83. package/dist/src/device-rotation.js +0 -14
  84. package/dist/src/logcat.js +0 -6
@@ -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() {
@@ -387,6 +387,7 @@ extension RunnerTests {
387
387
 
388
388
  func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
389
389
  NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
390
+ runnerAccessibilityHealth = .unavailable
390
391
  invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
391
392
  // This is a planned terminal result, so it carries the structured verdict like every other
392
393
  // planned snapshot — downstream sparse handling keys off the verdict, not node shapes.
@@ -526,12 +527,22 @@ extension RunnerTests {
526
527
  func makeSnapshotTraversalContext(
527
528
  app: XCUIApplication,
528
529
  options: SnapshotOptions,
529
- captureDeadline: Date = .distantFuture
530
+ captureDeadline: Date = .distantFuture,
531
+ treeCaptureSliceBudgetOverride: TimeInterval? = nil
530
532
  ) throws -> SnapshotTraversalContext? {
531
- let viewport = safeSnapshotViewport(app: app)
532
- 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
+ }
533
543
 
534
- let slice = min(treeCaptureSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
544
+ let treeSliceBudget = treeCaptureSliceBudgetOverride ?? treeCaptureSliceBudget
545
+ let slice = min(treeSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
535
546
  guard let rootSnapshot = try captureSnapshotRootBounded(queryRoot, sliceSeconds: slice) else {
536
547
  return nil
537
548
  }
@@ -547,7 +558,7 @@ extension RunnerTests {
547
558
  )
548
559
  }
549
560
 
550
- static let treeCaptureTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"
561
+ static let xCTestSnapshotTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"
551
562
 
552
563
  func hasAbandonedTreeCapture() -> Bool {
553
564
  treeCaptureLock.lock()
@@ -555,63 +566,60 @@ extension RunnerTests {
555
566
  return abandonedTreeCaptureCount > 0
556
567
  }
557
568
 
558
- /// Runs the blocking tree-snapshot XPC on a worker thread bounded by `sliceSeconds`. On
559
- /// timeout the XPC keeps running on its worker (it cannot be cancelled); the capture is
560
- /// marked abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is
561
- /// penalized for this bundle, and the plan moves on to the private AX backend (#1105).
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).
562
574
  private func captureSnapshotRootBounded(
563
575
  _ element: XCUIElement,
564
576
  sliceSeconds: TimeInterval
565
577
  ) throws -> XCUIElementSnapshot? {
566
- final class TreeCaptureBox {
567
- var abandoned = false
568
- var outcome: Result<XCUIElementSnapshot?, Error>?
578
+ if Thread.isMainThread {
579
+ return try captureSnapshotRoot(element)
569
580
  }
570
- let box = TreeCaptureBox()
571
- let semaphore = DispatchSemaphore(value: 0)
572
- DispatchQueue.global(qos: .userInitiated).async {
573
- var result: Result<XCUIElementSnapshot?, Error>
574
- do {
575
- result = .success(try self.captureSnapshotRoot(element))
576
- } catch {
577
- result = .failure(error)
578
- }
579
- self.treeCaptureLock.lock()
580
- if box.abandoned {
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()
581
597
  self.abandonedTreeCaptureCount -= 1
582
598
  self.treeCaptureLock.unlock()
583
599
  NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_DRAINED")
584
- } else {
585
- box.outcome = result
586
- self.treeCaptureLock.unlock()
587
600
  }
588
- semaphore.signal()
601
+ ) {
602
+ try self.captureSnapshotRoot(element)
589
603
  }
590
- if semaphore.wait(timeout: .now() + sliceSeconds) == .timedOut {
591
- treeCaptureLock.lock()
592
- let timedOut = box.outcome == nil
593
- if timedOut {
594
- box.abandoned = true
595
- abandonedTreeCaptureCount += 1
596
- }
597
- treeCaptureLock.unlock()
598
- if timedOut {
599
- NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds)
600
- penalizeSnapshotTreeBackend(bundleId: currentBundleId, reason: "tree_capture_slice_timeout")
601
- throw SnapshotCaptureFailure(
602
- code: Self.treeCaptureTimeoutCode,
603
- message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice",
604
- hint: "The capture plan recovers through the private AX backend on this screen."
605
- )
606
- }
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
+ )
607
613
  }
608
- switch box.outcome {
609
- case .success(let snapshot):
610
- return snapshot
611
- case .failure(let error):
612
- throw error
613
- case .none:
614
- return nil
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
+ )
615
623
  }
616
624
  }
617
625
 
@@ -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,44 +98,68 @@ extension RunnerTests {
63
98
  static let regularVisiblePlan: [SnapshotBackendKind] = [.recursiveTree, .querySweep, .privateAX]
64
99
  static let rawDiagnosticPlan: [SnapshotBackendKind] = [.recursiveTree, .privateAX]
65
100
 
66
- // MARK: Tree-backend penalty (cross-attempt memory, #1105)
101
+ // MARK: XCTest accessibility channel penalty (cross-attempt memory, #1105/#1156)
67
102
  //
68
103
  // On some deep/dynamic screens the XCTest bulk snapshot no longer fails fast with
69
104
  // kAXErrorIllegalArgument (the #758 signature) — it grinds for many seconds first. One slow
70
105
  // grind is tolerable; re-grinding on every subsequent capture of the same screen buries the
71
- // main thread past the execution watchdog. After a slow or abandoned tree capture, later
72
- // plans for the same bundle lead with the private AX backend until the penalty expires.
73
-
74
- func penalizeSnapshotTreeBackend(bundleId: String?, reason: String) {
75
- snapshotTreePenaltyLock.lock()
76
- snapshotTreePenaltyBundleId = bundleId
77
- snapshotTreePenaltyUntil = Date().addingTimeInterval(snapshotTreePenaltyDuration)
78
- snapshotTreePenaltyLock.unlock()
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()
79
114
  NSLog(
80
- "AGENT_DEVICE_RUNNER_SNAPSHOT_TREE_PENALIZED bundle=%@ reason=%@",
115
+ "AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=%@ reason=%@",
81
116
  bundleId ?? "",
82
117
  reason
83
118
  )
84
119
  }
85
120
 
86
- func isSnapshotTreeBackendPenalized(bundleId: String?) -> Bool {
87
- snapshotTreePenaltyLock.lock()
88
- defer { snapshotTreePenaltyLock.unlock() }
89
- guard Date() < snapshotTreePenaltyUntil else { return false }
121
+ func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool {
122
+ snapshotXCTestChannelPenaltyLock.lock()
123
+ defer { snapshotXCTestChannelPenaltyLock.unlock() }
124
+ guard Date() < snapshotXCTestChannelPenaltyUntil else { return false }
90
125
  // A penalty recorded without a bundle id applies to whatever target is current.
91
- guard let penalized = snapshotTreePenaltyBundleId else { return true }
126
+ guard let penalized = snapshotXCTestChannelPenaltyBundleId else { return true }
92
127
  return penalized == bundleId
93
128
  }
94
129
 
95
- /// Pure plan-reorder rule: a penalized tree backend defers to the private AX backend on the
96
- /// regular plan only. The raw diagnostic plan keeps tree-first errors, and unknown plans are
97
- /// left untouched.
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.
98
133
  static func effectiveSnapshotCapturePlan(
99
134
  _ plan: [SnapshotBackendKind],
100
- treePenalized: Bool
101
- ) -> (plan: [SnapshotBackendKind], treeDeferred: Bool) {
102
- guard treePenalized, plan == Self.regularVisiblePlan else { return (plan, false) }
103
- return ([.privateAX, .querySweep, .recursiveTree], true)
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()
104
163
  }
105
164
 
106
165
  // MARK: Plan runner
@@ -116,22 +175,34 @@ extension RunnerTests {
116
175
  var axFailure: SnapshotCaptureFailure?
117
176
  let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
118
177
 
119
- // Reorder is gated to iOS simulators the only place the private AX backend exists, so
120
- // the deferred plan cannot degrade platforms where the tree backend is the strongest tier.
121
- var treePenalized = false
122
- #if os(iOS) && targetEnvironment(simulator)
123
- treePenalized = isSnapshotTreeBackendPenalized(bundleId: currentBundleId)
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)
124
184
  #endif
125
- let (effectivePlan, treeDeferred) = Self.effectiveSnapshotCapturePlan(
185
+ let effective = Self.effectiveSnapshotCapturePlan(
126
186
  plan,
127
- treePenalized: treePenalized
187
+ xCTestChannelPenalized: xCTestChannelPenalized,
188
+ availableBackends: Set(SnapshotBackendKind.allCases.filter(\.isAvailableOnCurrentPlatform))
128
189
  )
129
- if treeDeferred {
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:
130
201
  firstFailure = (
131
- "the XCTest tree backend was deferred after a recent slow capture on this screen",
202
+ "XCTest-backed snapshot tiers are running with a short recovery probe after recent slow accessibility work on this screen",
132
203
  "budget"
133
204
  )
134
- NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TREE_DEFERRED bundle=%@", currentBundleId ?? "")
205
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PROBE_BOUNDED bundle=%@", currentBundleId ?? "")
135
206
  }
136
207
 
137
208
  for kind in effectivePlan {
@@ -143,8 +214,8 @@ extension RunnerTests {
143
214
  break
144
215
  }
145
216
  // While an abandoned tree capture is still grinding inside testmanagerd, XCTest-backed
146
- // tiers would block behind it; only the private AX backend stays responsive (#1105).
147
- if kind != .privateAX, hasAbandonedTreeCapture() {
217
+ // tiers would block behind it; only independent backends stay responsive (#1105).
218
+ if shouldSkipSnapshotBackendForAbandonedTreeCapture(kind) {
148
219
  NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue)
149
220
  if firstFailure == nil {
150
221
  firstFailure = (
@@ -157,15 +228,23 @@ extension RunnerTests {
157
228
  let capture: SnapshotBackendCapture
158
229
  let backendStartedAt = Date()
159
230
  do {
160
- guard let result = try captureWithBackend(kind, app: app, options: options, deadline: deadline)
231
+ guard
232
+ let result = try captureWithBackend(
233
+ kind,
234
+ app: app,
235
+ options: options,
236
+ deadline: deadline,
237
+ treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride
238
+ )
161
239
  else {
162
- recordSlowTreeBackendIfNeeded(kind, startedAt: backendStartedAt)
240
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
163
241
  continue
164
242
  }
165
243
  capture = result
166
- recordSlowTreeBackendIfNeeded(kind, startedAt: backendStartedAt)
244
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
167
245
  } catch let failure as SnapshotCaptureFailure {
168
- recordSlowTreeBackendIfNeeded(kind, startedAt: backendStartedAt)
246
+ recordXCTestSnapshotBackendFailureIfNeeded(kind, failure: failure)
247
+ recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
169
248
  if Self.isAxSnapshotFailure(failure) { axFailure = failure }
170
249
  if firstFailure == nil {
171
250
  firstFailure = (failure.message, Self.isAxSnapshotFailure(failure) ? "ax-rejected" : "capture-failed")
@@ -186,7 +265,7 @@ extension RunnerTests {
186
265
  continue
187
266
  }
188
267
 
189
- let recovered = kind != effectivePlan.first || treeDeferred
268
+ let recovered = kind != effectivePlan.first || effective.xCTestChannelState != .normal
190
269
  if recovered {
191
270
  NSLog(
192
271
  "AGENT_DEVICE_RUNNER_SNAPSHOT_RECOVERED backend=%@ reason=%@",
@@ -225,22 +304,33 @@ extension RunnerTests {
225
304
  best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: "sparse", reason: firstFailure) }
226
305
  ?? stampedSnapshotPayload(
227
306
  SnapshotBackendCapture(payload: sparseTruncatedSnapshotPayload(), effectiveDepth: nil),
228
- backend: plan.last ?? .recursiveTree,
307
+ backend: effectivePlan.last ?? plan.last ?? .recursiveTree,
229
308
  state: "sparse",
230
309
  reason: firstFailure
231
310
  )
232
311
  return fallbackPayload
233
312
  }
234
313
 
235
- /// Marks the tree backend as penalized when a capture attempt ground past the slow-capture
314
+ /// Marks XCTest-backed snapshot tiers as penalized when one attempt ground past the slow-capture
236
315
  /// threshold — even a successful one: the next capture of this screen must not re-grind.
237
- private func recordSlowTreeBackendIfNeeded(_ kind: SnapshotBackendKind, startedAt: Date) {
238
- guard kind == .recursiveTree else { return }
316
+ private func recordSlowXCTestSnapshotBackendIfNeeded(_ kind: SnapshotBackendKind, startedAt: Date) {
317
+ guard kind.usesXCTestAccessibilityChannel else { return }
239
318
  let elapsed = Date().timeIntervalSince(startedAt)
240
- guard elapsed > snapshotTreeSlowCaptureThreshold else { return }
241
- penalizeSnapshotTreeBackend(
319
+ guard elapsed > snapshotXCTestSlowCaptureThreshold else { return }
320
+ penalizeSnapshotXCTestChannel(
242
321
  bundleId: currentBundleId,
243
- reason: "slow_tree_capture_\(Int(elapsed * 1000))ms"
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"
244
334
  )
245
335
  }
246
336
 
@@ -248,7 +338,8 @@ extension RunnerTests {
248
338
  _ kind: SnapshotBackendKind,
249
339
  app: XCUIApplication,
250
340
  options: SnapshotOptions,
251
- deadline: Date
341
+ deadline: Date,
342
+ treeCaptureSliceBudgetOverride: TimeInterval?
252
343
  ) throws -> SnapshotBackendCapture? {
253
344
  switch kind {
254
345
  case .recursiveTree:
@@ -256,20 +347,31 @@ extension RunnerTests {
256
347
  let context = try makeSnapshotTraversalContext(
257
348
  app: app,
258
349
  options: options,
259
- captureDeadline: deadline
350
+ captureDeadline: deadline,
351
+ treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride
260
352
  )
261
353
  else {
262
354
  return nil
263
355
  }
264
- let payload = options.raw
265
- ? try rawTreeSnapshotPayload(context: context, options: options)
266
- : 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
+ }
267
365
  return SnapshotBackendCapture(payload: payload, effectiveDepth: nil)
268
366
  case .querySweep:
269
- return SnapshotBackendCapture(
270
- payload: snapshotFlatInteractive(app: app, options: options, planDeadline: deadline),
271
- effectiveDepth: nil
272
- )
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)
273
375
  case .privateAX:
274
376
  return privateAXSnapshotCapture(app: app, options: options, deadline: deadline)
275
377
  }
@@ -349,6 +451,7 @@ extension RunnerTests {
349
451
  state: String,
350
452
  reason: (reason: String, code: String)?
351
453
  ) -> DataPayload {
454
+ runnerAccessibilityHealth = reason?.code == "ax-rejected" ? .unavailable : .healthy
352
455
  let payload = capture.payload
353
456
  let quality = SnapshotQuality(
354
457
  state: state,
@@ -377,7 +480,7 @@ extension RunnerTests {
377
480
  ? " The primary capture ran out of its time budget (busy app or simulator); the recovered tree is authoritative for this screen."
378
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."
379
482
  parts.append(
380
- "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"
381
484
  + (quality.reason.map { " after: \($0)." } ?? ".")
382
485
  + meaning
383
486
  )
@@ -0,0 +1,142 @@
1
+ import XCTest
2
+
3
+ // Runner-local policy for AX-free/private synthesized iOS gestures.
4
+ //
5
+ // This policy is intentionally separate from the TS interaction guarantee matrix:
6
+ // ADR 0011 models element-targeting guarantees, while this module models command
7
+ // paths that must keep scroll/drag/sequence usable when XCTest AX is unhealthy.
8
+
9
+ enum RunnerAccessibilityHealth: String, Equatable {
10
+ case unknown
11
+ case healthy
12
+ case unavailable
13
+ }
14
+
15
+ enum SynthesizedKeyboardPolicy: String, Equatable, Hashable {
16
+ case never
17
+ case whenAccessibilityHealthy
18
+ case requiredWhenAvailable
19
+
20
+ func allowsProbe(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
21
+ switch self {
22
+ case .never:
23
+ return false
24
+ case .whenAccessibilityHealthy:
25
+ return accessibilityHealth == .healthy
26
+ case .requiredWhenAvailable:
27
+ return accessibilityHealth != .unavailable
28
+ }
29
+ }
30
+ }
31
+
32
+ enum SynthesizedFallbackPolicy: String, Equatable, Hashable {
33
+ case privateSynthesisRequired
34
+ case xctestCoordinateWhenAccessibilityAvailable
35
+ case xctestCoordinateAllowed
36
+
37
+ func allowsXCTestCoordinateFallback(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
38
+ switch self {
39
+ case .privateSynthesisRequired:
40
+ return false
41
+ case .xctestCoordinateWhenAccessibilityAvailable:
42
+ return accessibilityHealth != .unavailable
43
+ case .xctestCoordinateAllowed:
44
+ return true
45
+ }
46
+ }
47
+ }
48
+
49
+ enum SynthesizedGesturePolicyKind: String, Equatable, Hashable {
50
+ case coordinateTap
51
+ case scroll
52
+ case synthesizedDrag
53
+ }
54
+
55
+ struct SynthesizedGesturePolicy: Equatable, Hashable {
56
+ let keyboardPolicy: SynthesizedKeyboardPolicy
57
+ let fallbackPolicy: SynthesizedFallbackPolicy
58
+ }
59
+
60
+ struct SynthesizedCoordinateContext {
61
+ let referenceFrame: CGRect
62
+ let keyboardPolicy: SynthesizedKeyboardPolicy
63
+ let fallbackPolicy: SynthesizedFallbackPolicy
64
+ let accessibilityHealth: RunnerAccessibilityHealth
65
+
66
+ func withReferenceFrame(_ frame: CGRect) -> SynthesizedCoordinateContext {
67
+ SynthesizedCoordinateContext(
68
+ referenceFrame: frame,
69
+ keyboardPolicy: keyboardPolicy,
70
+ fallbackPolicy: fallbackPolicy,
71
+ accessibilityHealth: accessibilityHealth
72
+ )
73
+ }
74
+
75
+ var allowsXCTestCoordinateFallback: Bool {
76
+ fallbackPolicy.allowsXCTestCoordinateFallback(accessibilityHealth: accessibilityHealth)
77
+ }
78
+
79
+ var allowsKeyboardProbe: Bool {
80
+ keyboardPolicy.allowsProbe(accessibilityHealth: accessibilityHealth)
81
+ }
82
+ }
83
+
84
+ func synthesizedGesturePolicy(_ kind: SynthesizedGesturePolicyKind) -> SynthesizedGesturePolicy {
85
+ switch kind {
86
+ case .coordinateTap:
87
+ return SynthesizedGesturePolicy(
88
+ keyboardPolicy: .never,
89
+ fallbackPolicy: .xctestCoordinateAllowed
90
+ )
91
+ case .scroll:
92
+ return SynthesizedGesturePolicy(
93
+ keyboardPolicy: .whenAccessibilityHealthy,
94
+ fallbackPolicy: .privateSynthesisRequired
95
+ )
96
+ case .synthesizedDrag:
97
+ return SynthesizedGesturePolicy(
98
+ keyboardPolicy: .requiredWhenAvailable,
99
+ fallbackPolicy: .xctestCoordinateWhenAccessibilityAvailable
100
+ )
101
+ }
102
+ }
103
+
104
+ func sequenceHasSynthesizedCoordinateStep(_ steps: [SequenceStep]) -> Bool {
105
+ steps.contains { step in
106
+ step.synthesized == true && (step.kind == "tap" || step.kind == "drag")
107
+ }
108
+ }
109
+
110
+ extension RunnerTests {
111
+ func synthesizedSequenceCoordinateContext(steps: [SequenceStep]) -> SynthesizedCoordinateContext? {
112
+ guard sequenceHasSynthesizedCoordinateStep(steps) else { return nil }
113
+ return synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
114
+ }
115
+
116
+ func logSynthesizedGesturePolicyDecision(
117
+ kind: SynthesizedGesturePolicyKind,
118
+ context: SynthesizedCoordinateContext?,
119
+ fallbackAttempted: Bool
120
+ ) {
121
+ #if os(iOS)
122
+ guard let context else {
123
+ NSLog(
124
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ context=unavailable fallbackAttempted=%@",
125
+ kind.rawValue,
126
+ fallbackAttempted.description
127
+ )
128
+ return
129
+ }
130
+ NSLog(
131
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ axHealth=%@ frameSource=screenshot keyboardPolicy=%@ fallbackPolicy=%@ fallbackAllowed=%@ fallbackAttempted=%@",
132
+ kind.rawValue,
133
+ context.accessibilityHealth.rawValue,
134
+ context.keyboardPolicy.rawValue,
135
+ context.fallbackPolicy.rawValue,
136
+ context.allowsXCTestCoordinateFallback.description,
137
+ fallbackAttempted.description
138
+ )
139
+ #endif
140
+ }
141
+ }
142
+