agent-device 0.19.3 → 0.20.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 (179) hide show
  1. package/README.md +62 -70
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.apk +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.manifest.json +10 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.apk +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.apk.sha256 +1 -0
  7. package/{android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.manifest.json → android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.manifest.json} +6 -6
  8. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +2 -0
  9. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +5 -0
  10. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +5 -10
  11. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +74 -174
  12. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +113 -19
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift +82 -0
  14. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +325 -123
  15. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +2 -2
  16. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +149 -105
  17. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +56 -2
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +73 -0
  19. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +51 -26
  20. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +2 -2
  21. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +2 -107
  22. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +130 -13
  23. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +51 -8
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +24 -0
  25. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +1 -1
  26. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +43 -35
  27. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +1 -1
  28. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -1
  29. package/dist/src/agent-browser-tool.js +1 -1
  30. package/dist/src/agent-device-client.js +3 -0
  31. package/dist/src/android-adb.d.ts +1 -1
  32. package/dist/src/android-adb.js +1 -1
  33. package/dist/src/android-input-method-overlays.js +1 -0
  34. package/dist/src/android-input-ownership.js +1 -6
  35. package/dist/src/android-system-chrome.js +1 -0
  36. package/dist/src/android.js +1 -1
  37. package/dist/src/app-lifecycle.js +5 -5
  38. package/dist/src/app-log-request-scope.js +1 -0
  39. package/dist/src/app-resolution.js +1 -0
  40. package/dist/src/apple-multitouch-support.js +1 -0
  41. package/dist/src/apps.js +5 -5
  42. package/dist/src/boot-diagnostics.js +2 -0
  43. package/dist/src/cli-config.js +1 -0
  44. package/dist/src/cli-help.js +131 -40
  45. package/dist/src/cli-runner.js +1 -1
  46. package/dist/src/cli.js +15 -11
  47. package/dist/src/command-metadata.js +1 -1
  48. package/dist/src/command-schema.js +3 -3
  49. package/dist/src/compatibility-policy.js +1 -0
  50. package/dist/src/context.js +1 -0
  51. package/dist/src/contracts.d.ts +2 -2
  52. package/dist/src/debug-symbols.js +1 -1
  53. package/dist/src/device-input-state.js +1 -1
  54. package/dist/src/devices2.js +1 -1
  55. package/dist/src/devices4.js +1 -0
  56. package/dist/src/find.js +1 -1
  57. package/dist/src/finders.js +1 -1
  58. package/dist/src/generic.js +1 -1
  59. package/dist/src/helper.js +1 -1
  60. package/dist/src/index.d.ts +533 -279
  61. package/dist/src/index.js +1 -1
  62. package/dist/src/input-actions.js +4 -3
  63. package/dist/src/install-artifact.js +1 -0
  64. package/dist/src/install-source.d.ts +5 -1
  65. package/dist/src/install-source.js +1 -1
  66. package/dist/src/install-source2.js +1 -1
  67. package/dist/src/interaction-snapshot.js +1 -1
  68. package/dist/src/interaction.d.ts +230 -0
  69. package/dist/src/interaction.js +1 -1
  70. package/dist/src/interactor.js +2 -2
  71. package/dist/src/interactor2.js +1 -0
  72. package/dist/src/internal/bin.js +1 -1
  73. package/dist/src/internal/daemon.js +42 -20
  74. package/dist/src/internal/png-worker.d.ts +22 -0
  75. package/dist/src/internal/png-worker.js +1 -1
  76. package/dist/src/launch-console.js +1 -0
  77. package/dist/src/lease-context.js +1 -0
  78. package/dist/src/lease.js +1 -1
  79. package/dist/src/linux.js +1 -1
  80. package/dist/src/logcat.js +5 -0
  81. package/dist/src/method-scope.js +1 -0
  82. package/dist/src/metro-types.d.ts +52 -0
  83. package/dist/src/metro.d.ts +3 -3
  84. package/dist/src/notifications.js +1 -1
  85. package/dist/src/png-worker-contract.js +1 -1
  86. package/dist/src/process-lock.js +1 -0
  87. package/dist/src/react-native.js +1 -1
  88. package/dist/src/record-trace.js +1 -1
  89. package/dist/src/recording-export-quality.js +15 -0
  90. package/dist/src/register-builtins.js +1 -0
  91. package/dist/src/registry.js +24 -24
  92. package/dist/src/remote-config.d.ts +1 -1
  93. package/dist/src/reporting.js +8 -7
  94. package/dist/src/runner-client.js +1 -1
  95. package/dist/src/runner-disposal.js +2 -0
  96. package/dist/src/runner-lease.js +1 -0
  97. package/dist/src/runner-provider.js +1 -1
  98. package/dist/src/runner-sequence.js +1 -1
  99. package/dist/src/runtime.js +3 -3
  100. package/dist/src/runtime2.js +1 -0
  101. package/dist/src/sdk-android-adb.d.ts +97 -33
  102. package/dist/src/sdk-android-adb.js +4 -4
  103. package/dist/src/sdk-batch-runner.d.ts +2 -1
  104. package/dist/src/sdk-batch-runner.js +3 -2
  105. package/dist/src/sdk-contracts.d.ts +70 -149
  106. package/dist/src/sdk-contracts.js +1 -1
  107. package/dist/src/sdk-device.js +1 -1
  108. package/dist/src/sdk-finders.d.ts +5 -4
  109. package/dist/src/sdk-finders.js +1 -1
  110. package/dist/src/sdk-io.d.ts +1 -1
  111. package/dist/src/sdk-remote-config.d.ts +19 -96
  112. package/dist/src/sdk-remote-config.js +1 -1
  113. package/dist/src/sdk-selectors.js +1 -1
  114. package/dist/src/selector-runtime.js +1 -1
  115. package/dist/src/selector-vocabulary.js +1 -0
  116. package/dist/src/selectors.d.ts +12 -3
  117. package/dist/src/selectors.js +1 -1
  118. package/dist/src/server.js +3 -3
  119. package/dist/src/session-target-evidence.js +1 -0
  120. package/dist/src/session.js +8 -10
  121. package/dist/src/simctl.js +1 -1
  122. package/dist/src/simulator.js +1 -1
  123. package/dist/src/snapshot-diagnostics.js +5 -2
  124. package/dist/src/snapshot-quality.js +1 -1
  125. package/dist/src/snapshot.js +1 -1
  126. package/dist/src/snapshot2.js +1 -2
  127. package/dist/src/tool-provider3.js +1 -0
  128. package/dist/src/unsupported-interactor.js +1 -0
  129. package/dist/src/url.js +1 -1
  130. package/dist/src/web.js +1 -1
  131. package/package.json +34 -27
  132. package/server.json +2 -2
  133. package/skills/agent-device/SKILL.md +5 -3
  134. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.apk +0 -0
  135. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.apk.sha256 +0 -1
  136. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.manifest.json +0 -10
  137. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.apk +0 -0
  138. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.apk.sha256 +0 -1
  139. package/dist/src/apple-runner-options.js +0 -1
  140. package/dist/src/cli-output.js +0 -1
  141. package/dist/src/client.js +0 -3
  142. package/dist/src/handler-utils.js +0 -1
  143. package/dist/src/output-path.js +0 -1
  144. package/dist/src/points.js +0 -4
  145. package/dist/src/provider.js +0 -1
  146. package/dist/src/providers.js +0 -1
  147. package/dist/src/record-trace-recording.js +0 -23
  148. package/dist/src/recording-provider.js +0 -1
  149. package/dist/src/sdk-batch.js +0 -2
  150. package/dist/src/selectors2.js +0 -1
  151. package/dist/src/ttl-memo.js +0 -1
  152. package/dist/src/tv-remote.js +0 -14
  153. package/dist/src/xml.js +0 -2
  154. /package/{macos-helper → apple/macos-helper}/Package.swift +0 -0
  155. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/AudioProbe.swift +0 -0
  156. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift +0 -0
  157. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/main.swift +0 -0
  158. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +0 -0
  159. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +0 -0
  160. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -0
  161. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +0 -0
  162. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +0 -0
  163. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests.entitlements +0 -0
  164. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.h +0 -0
  165. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.m +0 -0
  166. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTapPointPolicy.swift +0 -0
  167. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +0 -0
  168. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +0 -0
  169. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +0 -0
  170. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +0 -0
  171. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +0 -0
  172. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +0 -0
  173. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +0 -0
  174. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +0 -0
  175. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +0 -0
  176. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +0 -0
  177. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -0
  178. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-trim.swift +0 -0
  179. /package/{src/platforms/linux → linux}/atspi-dump.py +0 -0
@@ -1,5 +1,6 @@
1
1
  import XCTest
2
2
 
3
+
3
4
  extension RunnerTests {
4
5
  // MARK: - Main Thread Dispatch
5
6
 
@@ -58,7 +59,7 @@ extension RunnerTests {
58
59
 
59
60
  /// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
60
61
  /// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
61
- /// gestures (pinch/rotate/transform) pass `false` because RunnerSynthesizedGesture governs its
62
+ /// pointer-plan gestures pass `false` because RunnerSynthesizedGesture governs their
62
63
  /// own timing. Returns the captured timing and the action's outcome.
63
64
  ///
64
65
  /// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
@@ -85,7 +86,8 @@ extension RunnerTests {
85
86
  message: String,
86
87
  timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
87
88
  frame: GestureFrame = .none,
88
- fallback: GestureFallback? = nil
89
+ fallback: GestureFallback? = nil,
90
+ maestroNonHittableCoordinateFallbackUsed: Bool? = nil
89
91
  ) -> Response {
90
92
  let data: DataPayload
91
93
  switch frame {
@@ -96,7 +98,8 @@ extension RunnerTests {
96
98
  gestureEndUptimeMs: timing.gestureEndUptimeMs,
97
99
  gestureFallback: fallback?.strategy,
98
100
  gestureFallbackMessage: fallback?.message,
99
- gestureFallbackHint: fallback?.hint
101
+ gestureFallbackHint: fallback?.hint,
102
+ maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed
100
103
  )
101
104
  case .touch(let f):
102
105
  data = DataPayload(
@@ -109,7 +112,8 @@ extension RunnerTests {
109
112
  referenceHeight: f?.referenceHeight,
110
113
  gestureFallback: fallback?.strategy,
111
114
  gestureFallbackMessage: fallback?.message,
112
- gestureFallbackHint: fallback?.hint
115
+ gestureFallbackHint: fallback?.hint,
116
+ maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed
113
117
  )
114
118
  case .drag(let f):
115
119
  data = DataPayload(
@@ -130,6 +134,24 @@ extension RunnerTests {
130
134
  return Response(ok: true, data: data)
131
135
  }
132
136
 
137
+ /// Gesture plans already return canonical centroid endpoints from the portable runtime.
138
+ /// Keep runner timing/fallback diagnostics, but do not leak the coordinate-drag adapter's
139
+ /// visualization frame into only the fast-fling response shape.
140
+ private func canonicalPlannedGestureResponse(_ response: Response) -> Response {
141
+ guard response.ok, let data = response.data else { return response }
142
+ return Response(
143
+ ok: true,
144
+ data: DataPayload(
145
+ message: data.message,
146
+ gestureStartUptimeMs: data.gestureStartUptimeMs,
147
+ gestureEndUptimeMs: data.gestureEndUptimeMs,
148
+ gestureFallback: data.gestureFallback,
149
+ gestureFallbackMessage: data.gestureFallbackMessage,
150
+ gestureFallbackHint: data.gestureFallbackHint
151
+ )
152
+ )
153
+ }
154
+
133
155
 
134
156
  func execute(command: Command) throws -> Response {
135
157
  if command.command == .status {
@@ -252,31 +274,71 @@ extension RunnerTests {
252
274
  )
253
275
  }
254
276
 
255
- private func executeDispatched(command: Command) throws -> Response {
256
- // XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue
257
- // block, queueing more main-thread commands behind it only buries the runner deeper.
258
- // Refuse fast instead so the daemon backs off while the abandoned work drains; past the
259
- // wedge threshold, escalate so the daemon recycles this runner (#1105).
277
+ private func runnerUnavailableResponse(command: Command) -> Response? {
260
278
  switch currentMainThreadBusyState() {
261
279
  case .idle:
262
- break
280
+ return nil
263
281
  case .busy(let abandonedForSeconds):
264
282
  return runnerBusyResponse(command: command, abandonedForSeconds: abandonedForSeconds)
265
283
  case .wedged(let abandonedForSeconds):
266
284
  return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds)
267
285
  }
286
+ }
287
+
288
+ private func executeDispatched(command: Command) throws -> Response {
289
+ // XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue
290
+ // block, queueing more main-thread commands behind it only buries the runner deeper.
291
+ // Refuse fast instead so the daemon backs off while the abandoned work drains; past the
292
+ // wedge threshold, escalate so the daemon recycles this runner (#1105).
293
+ if let unavailable = runnerUnavailableResponse(command: command) {
294
+ return unavailable
295
+ }
296
+ let alertDeadline = command.command == .alert
297
+ ? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs))
298
+ : nil
268
299
  if Thread.isMainThread {
269
- return try executeOnMainSafely(command: command)
300
+ let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(
301
+ command,
302
+ deadline: alertDeadline
303
+ )
304
+ return try executeOnMainSafely(
305
+ command: command,
306
+ alertDeadline: alertDeadline,
307
+ routeToSpringboard: routeToSpringboard
308
+ )
309
+ }
310
+ // Resolve this before the command's outer main-thread block. If the bounded probe abandons
311
+ // slow XCTest enumeration, return the established recoverable response instead of queueing
312
+ // command preparation behind work that may outlive the 30-second command watchdog.
313
+ let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(
314
+ command,
315
+ deadline: alertDeadline
316
+ )
317
+ if let unavailable = runnerUnavailableResponse(command: command) {
318
+ return unavailable
270
319
  }
271
320
  if command.command == .snapshot {
272
321
  return try executeSnapshotDispatched(command: command)
273
322
  }
323
+ if command.command == .alert, let deadline = alertDeadline {
324
+ return try runMainThreadWork(
325
+ command: command,
326
+ timeout: max(0.001, deadline.timeIntervalSinceNow),
327
+ timeoutError: mainThreadExecutionTimeoutError
328
+ ) {
329
+ try self.executeOnMainSafely(
330
+ command: command,
331
+ alertDeadline: deadline,
332
+ routeToSpringboard: routeToSpringboard
333
+ )
334
+ }
335
+ }
274
336
  return try runMainThreadWork(
275
337
  command: command,
276
338
  timeout: mainThreadExecutionTimeout,
277
339
  timeoutError: mainThreadExecutionTimeoutError
278
340
  ) {
279
- try self.executeOnMainSafely(command: command)
341
+ try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard)
280
342
  }
281
343
  }
282
344
 
@@ -354,7 +416,11 @@ extension RunnerTests {
354
416
 
355
417
  // MARK: - Command Handling
356
418
 
357
- private func executeOnMainSafely(command: Command) throws -> Response {
419
+ private func executeOnMainSafely(
420
+ command: Command,
421
+ alertDeadline: Date? = nil,
422
+ routeToSpringboard: Bool
423
+ ) throws -> Response {
358
424
  var hasRetried = false
359
425
  while true {
360
426
  var response: Response?
@@ -362,7 +428,11 @@ extension RunnerTests {
362
428
  let failureCountBefore = currentXCTestFailureCount()
363
429
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
364
430
  do {
365
- response = try self.executeOnMain(command: command)
431
+ response = try self.executeOnMain(
432
+ command: command,
433
+ alertDeadline: alertDeadline,
434
+ routeToSpringboard: routeToSpringboard
435
+ )
366
436
  } catch {
367
437
  swiftError = error
368
438
  }
@@ -416,6 +486,18 @@ extension RunnerTests {
416
486
  }
417
487
 
418
488
  private func executeSnapshotDispatched(command: Command) throws -> Response {
489
+ try executeDispatchedWithRecovery(command: command) {
490
+ try self.executeSnapshotDispatchedOnce(command: command)
491
+ }
492
+ }
493
+
494
+ /// The dispatched snapshot recovery loop: read-only retry + XCTest-recorded-failure invalidation,
495
+ /// matching what `executeOnMainSafely` gives the generic path. `perform` runs the capture and its
496
+ /// own bounded main-thread work.
497
+ func executeDispatchedWithRecovery(
498
+ command: Command,
499
+ perform: () throws -> Response
500
+ ) throws -> Response {
419
501
  var hasRetried = false
420
502
  while true {
421
503
  let failureCountBefore = try runMainThreadWork(
@@ -425,7 +507,16 @@ extension RunnerTests {
425
507
  ) {
426
508
  self.currentXCTestFailureCount()
427
509
  }
428
- let response = try executeSnapshotDispatchedOnce(command: command)
510
+ let response = try perform()
511
+ // Recovered independently — re-entering main for bookkeeping would queue behind the still-
512
+ // abandoned XCTest query and re-stall the command (#1244), so skip it until that work drains.
513
+ if hasAbandonedTreeCapture() {
514
+ NSLog(
515
+ "AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@",
516
+ command.command.rawValue
517
+ )
518
+ return response
519
+ }
429
520
  let recordedFailureResponse = try runMainThreadWork(
430
521
  command: command,
431
522
  timeout: mainThreadExecutionTimeout,
@@ -471,7 +562,7 @@ extension RunnerTests {
471
562
  timeout: mainThreadExecutionTimeout,
472
563
  timeoutError: mainThreadExecutionTimeoutError
473
564
  ) {
474
- try self.prepareActiveCommandContextSafely(command: command)
565
+ try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false)
475
566
  }
476
567
  switch preparation {
477
568
  case .response(let response):
@@ -550,10 +641,16 @@ extension RunnerTests {
550
641
  }
551
642
  }
552
643
 
553
- private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation {
644
+ private func prepareActiveCommandContextSafely(
645
+ command: Command,
646
+ routeToSpringboard: Bool
647
+ ) throws -> ActiveCommandPreparation {
554
648
  var preparation: ActiveCommandPreparation?
555
649
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
556
- preparation = self.prepareActiveCommandContext(command: command)
650
+ preparation = self.prepareActiveCommandContext(
651
+ command: command,
652
+ routeToSpringboard: routeToSpringboard
653
+ )
557
654
  })
558
655
  if let exceptionMessage {
559
656
  throw NSError(
@@ -572,8 +669,15 @@ extension RunnerTests {
572
669
  return preparation
573
670
  }
574
671
 
575
- private func executeOnMain(command: Command) throws -> Response {
576
- let preparation = prepareActiveCommandContext(command: command)
672
+ private func executeOnMain(
673
+ command: Command,
674
+ alertDeadline: Date?,
675
+ routeToSpringboard: Bool
676
+ ) throws -> Response {
677
+ let preparation = prepareActiveCommandContext(
678
+ command: command,
679
+ routeToSpringboard: routeToSpringboard
680
+ )
577
681
  let activeApp: XCUIApplication
578
682
  switch preparation {
579
683
  case .response(let response):
@@ -585,6 +689,8 @@ extension RunnerTests {
585
689
  switch command.command {
586
690
  case .status:
587
691
  return executeStatus(command: command)
692
+ case .targetReset:
693
+ return resetTargetAfterExternalRelaunch()
588
694
  case .shutdown:
589
695
  stopRecordingIfNeeded()
590
696
  return Response(ok: true, data: DataPayload(message: "shutdown"))
@@ -649,15 +755,46 @@ extension RunnerTests {
649
755
  }
650
756
  case .uptime:
651
757
  return executeUptime()
758
+ case .activate:
759
+ guard
760
+ let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
761
+ !bundleId.isEmpty
762
+ else {
763
+ return Response(ok: false, error: ErrorPayload(message: "activate requires appBundleId"))
764
+ }
765
+ // prepareActiveCommandContext already activated this bundle. Keep this case as the
766
+ // explicit acknowledgement after that preflight, not as a second activation.
767
+ return Response(ok: true, data: DataPayload(message: "app activated"))
768
+ case .terminate:
769
+ guard
770
+ let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
771
+ !bundleId.isEmpty
772
+ else {
773
+ return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId"))
774
+ }
775
+ XCUIApplication(bundleIdentifier: bundleId).terminate()
776
+ if currentBundleId == bundleId {
777
+ invalidateCachedTarget(reason: "target_terminated")
778
+ }
779
+ return Response(ok: true, data: DataPayload(message: "app terminated"))
652
780
  default:
653
781
  break
654
782
  }
655
- return try executeOnMainPrepared(command: command, activeApp: activeApp)
783
+ return try executeOnMainPrepared(
784
+ command: command,
785
+ activeApp: activeApp,
786
+ alertDeadline: alertDeadline
787
+ )
656
788
  }
657
789
 
658
- private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation {
790
+ private func prepareActiveCommandContext(
791
+ command: Command,
792
+ routeToSpringboard: Bool = false
793
+ ) -> ActiveCommandPreparation {
659
794
  var activeApp = currentApp ?? app
660
- if shouldSkipAppActivationPreflight(command) {
795
+ if routeToSpringboard {
796
+ activeApp = springboard
797
+ } else if shouldSkipAppActivationPreflight(command) {
661
798
  activeApp = resolveAppWithoutActivation(command: command)
662
799
  } else if !isRunnerLifecycleCommand(command.command) {
663
800
  let normalizedBundleId = command.appBundleId?
@@ -666,11 +803,12 @@ extension RunnerTests {
666
803
  if let bundleId = requestedBundleId {
667
804
  if currentBundleId != bundleId || currentApp == nil {
668
805
  _ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
806
+ } else {
807
+ refreshCachedTargetIfProcessChanged(bundleId: bundleId)
669
808
  }
670
809
  } else {
671
810
  // Do not reuse stale bundle targets when the caller does not explicitly request one.
672
- currentApp = nil
673
- currentBundleId = nil
811
+ invalidateCachedTarget(reason: "missing_app_bundle")
674
812
  }
675
813
 
676
814
  activeApp = currentApp ?? app
@@ -721,10 +859,14 @@ extension RunnerTests {
721
859
  return .context(ActiveCommandContext(app: activeApp))
722
860
  }
723
861
 
724
- private func executeOnMainPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
862
+ private func executeOnMainPrepared(
863
+ command: Command,
864
+ activeApp: XCUIApplication,
865
+ alertDeadline: Date? = nil
866
+ ) throws -> Response {
725
867
  var activeApp = activeApp
726
868
  switch command.command {
727
- case .status, .shutdown, .recordStart, .recordStop, .uptime:
869
+ case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
728
870
  return Response(
729
871
  ok: false,
730
872
  error: ErrorPayload(
@@ -734,11 +876,21 @@ extension RunnerTests {
734
876
  )
735
877
  case .tap:
736
878
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
879
+ let expectedPoint: CGPoint?
880
+ if command.allowNonHittableCoordinateFallback == true,
881
+ let x = command.x,
882
+ let y = command.y
883
+ {
884
+ expectedPoint = CGPoint(x: x, y: y)
885
+ } else {
886
+ expectedPoint = nil
887
+ }
737
888
  let match = findElement(
738
889
  app: activeApp,
739
890
  selectorKey: selectorKey,
740
891
  selectorValue: selectorValue,
741
- allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
892
+ allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true,
893
+ expectedPoint: expectedPoint
742
894
  )
743
895
  if match.isAmbiguous {
744
896
  return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
@@ -764,15 +916,19 @@ extension RunnerTests {
764
916
  message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
765
917
  }
766
918
  let isTextEntry = isTextEntryElement(element)
767
- let touchFrame = frame.isEmpty
768
- ? nil
769
- : resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
919
+ let touchPoint = expectedPoint ?? CGPoint(x: frame.midX, y: frame.midY)
920
+ let touchFrame = resolvedTouchVisualizationFrame(
921
+ app: activeApp,
922
+ x: touchPoint.x,
923
+ y: touchPoint.y
924
+ )
770
925
  let (timing, outcome) = performGesture(activeApp) {
771
- if match.usedNonHittableFallback {
926
+ if expectedPoint != nil || match.usedNonHittableFallback {
772
927
  // Maestro compatibility: RN E2E backdoor controls can be 1x1 and
773
- // reported non-hittable by XCTest, while Maestro still taps their
774
- // resolved bounds. Keep this behind the explicit replay-only flag.
775
- return tapAt(app: activeApp, x: frame.midX, y: frame.midY)
928
+ // reported non-hittable by XCTest. Snapshot-resolved targets also
929
+ // need coordinate delivery because XCUIElement.activate() does not
930
+ // invoke every React Native accessibility wrapper.
931
+ return tapAt(app: activeApp, x: touchPoint.x, y: touchPoint.y)
776
932
  }
777
933
  return activateElement(app: activeApp, element: element, action: "tap by selector")
778
934
  }
@@ -785,7 +941,11 @@ extension RunnerTests {
785
941
  return gestureResponse(
786
942
  message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
787
943
  timing: timing,
788
- frame: .touch(touchFrame)
944
+ frame: .touch(touchFrame),
945
+ maestroNonHittableCoordinateFallbackUsed:
946
+ command.allowNonHittableCoordinateFallback == true
947
+ ? match.usedNonHittableFallback
948
+ : nil
789
949
  )
790
950
  }
791
951
  return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
@@ -867,7 +1027,6 @@ extension RunnerTests {
867
1027
  durationMs: command.durationMs,
868
1028
  synthesized: command.synthesized == true,
869
1029
  message: "dragged",
870
- dragSemantics: command.dragSemantics,
871
1030
  synthesizedPolicyKind: .synthesizedDrag
872
1031
  )
873
1032
  case .scroll:
@@ -1094,6 +1253,12 @@ extension RunnerTests {
1094
1253
  guard let pngData = runnerPngData(for: screenshot.image) else {
1095
1254
  return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
1096
1255
  }
1256
+ if command.inlineScreenshot == true {
1257
+ return Response(
1258
+ ok: true,
1259
+ data: DataPayload(imageBase64: pngData.base64EncodedString())
1260
+ )
1261
+ }
1097
1262
  let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
1098
1263
  let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
1099
1264
  do {
@@ -1127,15 +1292,35 @@ extension RunnerTests {
1127
1292
  else {
1128
1293
  return Response(ok: false, error: ErrorPayload(message: "rotate requires orientation"))
1129
1294
  }
1130
- if rotateDevice(to: orientation) {
1295
+ let supportedOrientations = [
1296
+ "portrait",
1297
+ "portrait-upside-down",
1298
+ "landscape-left",
1299
+ "landscape-right",
1300
+ ]
1301
+ guard supportedOrientations.contains(orientation) else {
1131
1302
  return Response(
1132
- ok: true,
1133
- data: DataPayload(message: "rotate", orientation: orientation)
1303
+ ok: false,
1304
+ error: ErrorPayload(message: "unsupported rotate orientation: \(orientation)")
1305
+ )
1306
+ }
1307
+ guard let observedOrientation = rotateDevice(to: orientation) else {
1308
+ return Response(
1309
+ ok: false,
1310
+ error: ErrorPayload(message: "unable to observe device orientation after rotate")
1311
+ )
1312
+ }
1313
+ guard observedOrientation == orientation else {
1314
+ return Response(
1315
+ ok: false,
1316
+ error: ErrorPayload(
1317
+ message: "device orientation is \(observedOrientation), expected \(orientation)"
1318
+ )
1134
1319
  )
1135
1320
  }
1136
1321
  return Response(
1137
- ok: false,
1138
- error: ErrorPayload(message: "unsupported rotate orientation: \(orientation)")
1322
+ ok: true,
1323
+ data: DataPayload(message: "rotate", orientation: observedOrientation)
1139
1324
  )
1140
1325
  case .appSwitcher:
1141
1326
  performAppSwitcherGesture(app: activeApp)
@@ -1183,83 +1368,60 @@ extension RunnerTests {
1183
1368
  )
1184
1369
  case .alert:
1185
1370
  let action = (command.action ?? "get").lowercased()
1186
- guard let alert = resolveAlert(app: activeApp) else {
1371
+ let deadline = alertDeadline ?? Date().addingTimeInterval(
1372
+ Self.alertCommandTimeout(timeoutMs: command.timeoutMs)
1373
+ )
1374
+ guard let alert = resolveAlert(app: activeApp, deadline: deadline) else {
1187
1375
  return Response(ok: false, error: ErrorPayload(message: "alert not found"))
1188
1376
  }
1189
- return handleAlert(alert, action: action)
1190
- case .pinch:
1191
- guard let scale = command.scale, scale > 0 else {
1192
- return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
1193
- }
1194
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1195
- pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
1196
- }
1197
- if let response = unsupportedResponse(for: outcome) {
1198
- return response
1199
- }
1200
- return gestureResponse(message: "pinched", timing: timing)
1201
- case .sequence:
1202
- return executeSequence(command: command, activeApp: activeApp)
1203
- case .rotateGesture:
1204
- guard let degrees = command.degrees, degrees.isFinite else {
1205
- return Response(ok: false, error: ErrorPayload(message: "rotateGesture requires degrees"))
1206
- }
1207
- let velocity = command.velocity ?? (degrees >= 0 ? 1.0 : -1.0)
1208
- guard velocity.isFinite && velocity != 0 else {
1209
- return Response(ok: false, error: ErrorPayload(message: "rotateGesture velocity must be non-zero"))
1210
- }
1211
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1212
- rotateGesture(
1213
- app: activeApp,
1214
- degrees: degrees,
1215
- x: command.x,
1216
- y: command.y,
1217
- velocity: velocity
1377
+ return handleAlert(alert, action: action, deadline: deadline)
1378
+ case .gesture:
1379
+ guard let plan = command.gesturePlan else {
1380
+ return Response(
1381
+ ok: false,
1382
+ error: ErrorPayload(code: "INVALID_ARGS", message: "gesture requires gesturePlan")
1218
1383
  )
1219
1384
  }
1220
- if let response = unsupportedResponse(for: outcome) {
1221
- return response
1222
- }
1223
- return gestureResponse(message: "rotatedGesture", timing: timing)
1224
- case .transformGesture:
1225
- guard
1226
- let x = command.x,
1227
- let y = command.y,
1228
- let dx = command.dx,
1229
- let dy = command.dy,
1230
- x.isFinite,
1231
- y.isFinite,
1232
- dx.isFinite,
1233
- dy.isFinite
1234
- else {
1235
- return Response(ok: false, error: ErrorPayload(message: "transformGesture requires finite x y dx dy"))
1236
- }
1237
- guard let scale = command.scale, scale.isFinite, scale > 0 else {
1238
- return Response(ok: false, error: ErrorPayload(message: "transformGesture requires scale > 0"))
1239
- }
1240
- guard let degrees = command.degrees, degrees.isFinite else {
1241
- return Response(ok: false, error: ErrorPayload(message: "transformGesture requires finite degrees"))
1385
+ if let validationError = plannedGestureValidationError(plan) {
1386
+ return Response(
1387
+ ok: false,
1388
+ error: ErrorPayload(code: "INVALID_ARGS", message: validationError)
1389
+ )
1242
1390
  }
1243
- let durationMs = command.durationMs ?? 300
1244
- guard durationMs.isFinite && durationMs >= 16 else {
1245
- return Response(ok: false, error: ErrorPayload(message: "transformGesture durationMs must be >= 16"))
1391
+ if plannedGestureExecution(for: plan) == .fastSwipe {
1392
+ // Validation above guarantees a non-empty, single-pointer path for this execution kind.
1393
+ let first = plan.pointers[0].samples.first!.point
1394
+ let last = plan.pointers[0].samples.last!.point
1395
+ return canonicalPlannedGestureResponse(
1396
+ executeDragGesture(
1397
+ activeApp: activeApp,
1398
+ x: first.x,
1399
+ y: first.y,
1400
+ x2: last.x,
1401
+ y2: last.y,
1402
+ durationMs: plan.durationMs,
1403
+ synthesized: true,
1404
+ message: plan.intent,
1405
+ synthesizedPolicyKind: .synthesizedDrag,
1406
+ synthesizedProfile: .fastSwipe
1407
+ )
1408
+ )
1246
1409
  }
1247
1410
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1248
- transformGesture(
1249
- app: activeApp,
1250
- x: x,
1251
- y: y,
1252
- dx: dx,
1253
- dy: dy,
1254
- scale: scale,
1255
- degrees: degrees,
1256
- durationMs: durationMs
1257
- )
1411
+ sampledPlannedGesture(app: activeApp, plan: plan)
1258
1412
  }
1259
1413
  if let response = unsupportedResponse(for: outcome) {
1260
1414
  return response
1261
1415
  }
1262
- return gestureResponse(message: "transformedGesture", timing: timing)
1416
+ return gestureResponse(message: plan.intent, timing: timing)
1417
+ case .gestureViewport:
1418
+ let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
1419
+ guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
1420
+ return Response(ok: false, error: ErrorPayload(code: "COMMAND_FAILED", message: "Active app interaction viewport is unavailable"))
1421
+ }
1422
+ return Response(ok: true, data: DataPayload(message: "gestureViewport", x: frame.minX, y: frame.minY, x2: frame.width, y2: frame.height))
1423
+ case .sequence:
1424
+ return executeSequence(command: command, activeApp: activeApp)
1263
1425
  }
1264
1426
  }
1265
1427
 
@@ -1275,9 +1437,9 @@ extension RunnerTests {
1275
1437
  durationMs: Double?,
1276
1438
  synthesized: Bool,
1277
1439
  message: String,
1278
- dragSemantics: SynthesizedDragSemantics? = nil,
1279
1440
  synthesizedContext: SynthesizedCoordinateContext? = nil,
1280
- synthesizedPolicyKind: SynthesizedGesturePolicyKind
1441
+ synthesizedPolicyKind: SynthesizedGesturePolicyKind,
1442
+ synthesizedProfile: SynthesizedDragProfile = .continuous
1281
1443
  ) -> Response {
1282
1444
  let commandName = dragCommandName(message: message)
1283
1445
  guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
@@ -1294,9 +1456,9 @@ extension RunnerTests {
1294
1456
  y2: y2,
1295
1457
  durationMs: durationMs,
1296
1458
  message: message,
1297
- dragSemantics: dragSemantics,
1298
1459
  context: synthesizedContext,
1299
- policyKind: synthesizedPolicyKind
1460
+ policyKind: synthesizedPolicyKind,
1461
+ profile: synthesizedProfile
1300
1462
  ) {
1301
1463
  return synthesizedResponse
1302
1464
  }
@@ -1320,7 +1482,7 @@ extension RunnerTests {
1320
1482
  x2: dragPoints.x2,
1321
1483
  y2: dragPoints.y2,
1322
1484
  durationMs: durationMs,
1323
- semantics: dragSemantics,
1485
+ profile: synthesizedProfile,
1324
1486
  context: context
1325
1487
  )
1326
1488
  }
@@ -1361,9 +1523,9 @@ extension RunnerTests {
1361
1523
  y2: Double,
1362
1524
  durationMs: Double?,
1363
1525
  message: String,
1364
- dragSemantics: SynthesizedDragSemantics?,
1365
1526
  context: SynthesizedCoordinateContext?,
1366
- policyKind: SynthesizedGesturePolicyKind
1527
+ policyKind: SynthesizedGesturePolicyKind,
1528
+ profile: SynthesizedDragProfile
1367
1529
  ) -> Response? {
1368
1530
  #if os(iOS)
1369
1531
  let policy = synthesizedGesturePolicy(policyKind)
@@ -1415,7 +1577,7 @@ extension RunnerTests {
1415
1577
  x2: plan.points.x2,
1416
1578
  y2: plan.points.y2,
1417
1579
  durationMs: durationMs,
1418
- semantics: dragSemantics,
1580
+ profile: profile,
1419
1581
  context: plan.context
1420
1582
  )
1421
1583
  }
@@ -1535,23 +1697,58 @@ extension RunnerTests {
1535
1697
 
1536
1698
  private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
1537
1699
  #if os(iOS)
1538
- // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not
1700
+ if command.command == .alert {
1701
+ return true
1702
+ }
1703
+ // Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not
1539
1704
  // need app activation, window lookup, keyboard lookup, or element resolution. Selector/text
1540
1705
  // interactions intentionally stay on the normal AX path because they need an element query.
1541
1706
  // Scroll/drag/sequence keep the normal foreground guard and stabilization path.
1542
1707
  guard command.text == nil, command.selectorKey == nil else { return false }
1543
1708
  guard hasCachedTargetForActivationSkip(command: command) else { return false }
1544
- return command.command == .tap
1545
- && command.synthesized == true
1546
- && command.x != nil
1547
- && command.y != nil
1709
+ return isCoordinateOnlyTap(command)
1548
1710
  #else
1549
1711
  return false
1550
1712
  #endif
1551
1713
  }
1552
1714
 
1715
+ private func shouldRouteToSpringboardBlockingSystemModal(
1716
+ _ command: Command,
1717
+ deadline: Date? = nil
1718
+ ) -> Bool {
1719
+ #if os(iOS)
1720
+ guard command.command == .alert || isCoordinateOnlyTap(command) else {
1721
+ return false
1722
+ }
1723
+ let budgetDeadline = Date().addingTimeInterval(systemModalProbeBudget)
1724
+ let probeDeadline = deadline.map { min($0, budgetDeadline) } ?? budgetDeadline
1725
+ // `runMainThreadWork` executes inline for a main-thread caller, so that path cannot use its
1726
+ // timeout machinery. Direct main-thread dispatch keeps the prior synchronous modal check;
1727
+ // normal off-main command dispatch uses the bounded probe and post-probe busy recovery.
1728
+ if Thread.isMainThread {
1729
+ return firstBlockingSystemModal(
1730
+ in: springboard,
1731
+ deadline: probeDeadline
1732
+ ) != nil
1733
+ }
1734
+ return boundedBlockingSystemAlertSnapshot(
1735
+ deadline: probeDeadline
1736
+ ) != nil
1737
+ #else
1738
+ return false
1739
+ #endif
1740
+ }
1741
+
1742
+ private func isCoordinateOnlyTap(_ command: Command) -> Bool {
1743
+ return command.command == .tap
1744
+ && command.text == nil
1745
+ && command.selectorKey == nil
1746
+ && command.x != nil
1747
+ && command.y != nil
1748
+ }
1749
+
1553
1750
  private func hasCachedTargetForActivationSkip(command: Command) -> Bool {
1554
- guard currentApp != nil else { return false }
1751
+ guard let currentApp, currentApp.state == .runningForeground else { return false }
1555
1752
  guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
1556
1753
  !bundleId.isEmpty
1557
1754
  else {
@@ -1580,6 +1777,7 @@ extension RunnerTests {
1580
1777
  let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
1581
1778
  let textEntryMode = resolveTextEntryMode(command)
1582
1779
  let target: TextEntryTarget
1780
+ var maestroNonHittableCoordinateFallbackUsed: Bool?
1583
1781
  let focusStartedAt = Date()
1584
1782
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
1585
1783
  let match = findElement(
@@ -1597,6 +1795,9 @@ extension RunnerTests {
1597
1795
  guard isTextEntryElement(element) else {
1598
1796
  return Response(ok: false, error: ErrorPayload(code: "INVALID_TARGET", message: "selector did not match a text input"))
1599
1797
  }
1798
+ if command.allowNonHittableCoordinateFallback == true {
1799
+ maestroNonHittableCoordinateFallbackUsed = match.usedNonHittableFallback
1800
+ }
1600
1801
  target = focusTextInputForTextEntry(app: activeApp, element: element)
1601
1802
  } else {
1602
1803
  target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
@@ -1645,7 +1846,8 @@ extension RunnerTests {
1645
1846
  x: point.map { Double($0.x) },
1646
1847
  y: point.map { Double($0.y) },
1647
1848
  referenceWidth: frame.isEmpty ? nil : Double(frame.width),
1648
- referenceHeight: frame.isEmpty ? nil : Double(frame.height)
1849
+ referenceHeight: frame.isEmpty ? nil : Double(frame.height),
1850
+ maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed
1649
1851
  )
1650
1852
  )
1651
1853
  }