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
@@ -130,58 +130,6 @@ extension RunnerTests {
130
130
  return Response(ok: true, data: data)
131
131
  }
132
132
 
133
- #if AGENT_DEVICE_RUNNER_UNIT_TESTS
134
- func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
135
- let response = gestureResponse(
136
- message: "tapped",
137
- timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
138
- fallback: GestureFallback(
139
- strategy: "xctest-coordinate-tap",
140
- message: "Runner synthesized coordinate tap is unavailable",
141
- hint: "Using XCTest coordinate tap fallback."
142
- )
143
- )
144
-
145
- XCTAssertEqual(response.ok, true)
146
- XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
147
- XCTAssertEqual(
148
- response.data?.gestureFallbackMessage,
149
- "Runner synthesized coordinate tap is unavailable"
150
- )
151
- XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
152
- }
153
-
154
- func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
155
- let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
156
- let response = Response(ok: true, data: DataPayload(message: "tapped"))
157
-
158
- let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
159
-
160
- XCTAssertEqual(failureResponse?.ok, false)
161
- XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
162
- XCTAssertEqual(
163
- failureResponse?.error?.message,
164
- "XCTest recorded a failure while executing tap; the action may not have been performed."
165
- )
166
- }
167
-
168
- func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
169
- let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
170
- let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
171
- let runnerFatalResponse = Response(
172
- ok: true,
173
- data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
174
- )
175
-
176
- XCTAssertNil(
177
- xctestRecordedFailureResponse(
178
- command: snapshotCommand,
179
- response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
180
- )
181
- )
182
- XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
183
- }
184
- #endif
185
133
 
186
134
  func execute(command: Command) throws -> Response {
187
135
  if command.command == .status {
@@ -233,32 +181,158 @@ extension RunnerTests {
233
181
  )
234
182
  }
235
183
 
184
+ /// Tracks one main-queue dispatch so the watchdog and the dispatched block can agree —
185
+ /// under `mainThreadWorkLock` — on exactly one of: finished in time, or abandoned.
186
+ private final class MainThreadWorkState {
187
+ var finished = false
188
+ var abandoned = false
189
+ }
190
+
191
+ struct ActiveCommandContext {
192
+ let app: XCUIApplication
193
+ }
194
+
195
+ enum ActiveCommandPreparation {
196
+ case response(Response)
197
+ case context(ActiveCommandContext)
198
+ }
199
+
200
+ enum MainThreadBusyState {
201
+ case idle
202
+ case busy(abandonedForSeconds: TimeInterval)
203
+ case wedged(abandonedForSeconds: TimeInterval)
204
+ }
205
+
206
+ func currentMainThreadBusyState() -> MainThreadBusyState {
207
+ mainThreadWorkLock.lock()
208
+ defer { mainThreadWorkLock.unlock() }
209
+ guard abandonedMainThreadWorkCount > 0 else { return .idle }
210
+ let abandonedFor = abandonedMainThreadWorkSince.map { Date().timeIntervalSince($0) } ?? 0
211
+ if abandonedFor > mainThreadWedgeThreshold {
212
+ return .wedged(abandonedForSeconds: abandonedFor)
213
+ }
214
+ return .busy(abandonedForSeconds: abandonedFor)
215
+ }
216
+
217
+ private func runnerBusyResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
218
+ NSLog(
219
+ "AGENT_DEVICE_RUNNER_BUSY command=%@ commandId=%@ abandonedForSeconds=%.1f",
220
+ command.command.rawValue,
221
+ command.commandId ?? "",
222
+ abandonedForSeconds
223
+ )
224
+ return Response(
225
+ ok: false,
226
+ error: ErrorPayload(
227
+ code: "RUNNER_BUSY",
228
+ message:
229
+ "The iOS runner is still finishing a previous command that exceeded its execution watchdog (usually an accessibility capture on a heavy or animating screen).",
230
+ hint:
231
+ "Wait a few seconds and retry. If snapshots keep failing on this screen, use screenshot as visual truth and interact by coordinates, or navigate to another screen."
232
+ )
233
+ )
234
+ }
235
+
236
+ private func runnerWedgedResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
237
+ NSLog(
238
+ "AGENT_DEVICE_RUNNER_WEDGED command=%@ commandId=%@ abandonedForSeconds=%.1f",
239
+ command.command.rawValue,
240
+ command.commandId ?? "",
241
+ abandonedForSeconds
242
+ )
243
+ return Response(
244
+ ok: false,
245
+ error: ErrorPayload(
246
+ code: "RUNNER_WEDGED",
247
+ message:
248
+ "The iOS runner main thread has been stuck in abandoned work for \(Int(abandonedForSeconds)) seconds and cannot recover on its own.",
249
+ hint:
250
+ "The runner session will be restarted. Retry the command after the restart; if this screen keeps wedging captures, use screenshot as visual truth and interact by coordinates."
251
+ )
252
+ )
253
+ }
254
+
236
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).
260
+ switch currentMainThreadBusyState() {
261
+ case .idle:
262
+ break
263
+ case .busy(let abandonedForSeconds):
264
+ return runnerBusyResponse(command: command, abandonedForSeconds: abandonedForSeconds)
265
+ case .wedged(let abandonedForSeconds):
266
+ return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds)
267
+ }
237
268
  if Thread.isMainThread {
238
269
  return try executeOnMainSafely(command: command)
239
270
  }
240
- var result: Result<Response, Error>?
271
+ if command.command == .snapshot {
272
+ return try executeSnapshotDispatched(command: command)
273
+ }
274
+ return try runMainThreadWork(
275
+ command: command,
276
+ timeout: mainThreadExecutionTimeout,
277
+ timeoutError: mainThreadExecutionTimeoutError
278
+ ) {
279
+ try self.executeOnMainSafely(command: command)
280
+ }
281
+ }
282
+
283
+ func runMainThreadWork<T>(
284
+ command: Command?,
285
+ timeout: TimeInterval,
286
+ timeoutError: @escaping () -> Error,
287
+ onAbandoned: (() -> Void)? = nil,
288
+ onDrained: (() -> Void)? = nil,
289
+ _ work: @escaping () throws -> T
290
+ ) throws -> T {
291
+ if Thread.isMainThread {
292
+ return try work()
293
+ }
294
+ var result: Result<T, Error>?
241
295
  let semaphore = DispatchSemaphore(value: 0)
296
+ let workState = MainThreadWorkState()
242
297
  DispatchQueue.main.async {
243
298
  do {
244
- result = .success(try self.executeOnMainSafely(command: command))
299
+ result = .success(try work())
245
300
  } catch {
246
301
  result = .failure(error)
247
302
  }
303
+ self.mainThreadWorkLock.lock()
304
+ if workState.abandoned {
305
+ self.abandonedMainThreadWorkCount -= 1
306
+ if self.abandonedMainThreadWorkCount == 0 {
307
+ self.abandonedMainThreadWorkSince = nil
308
+ NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED")
309
+ }
310
+ self.mainThreadWorkLock.unlock()
311
+ onDrained?()
312
+ } else {
313
+ workState.finished = true
314
+ self.mainThreadWorkLock.unlock()
315
+ }
248
316
  semaphore.signal()
249
317
  }
250
- let waitResult = semaphore.wait(timeout: .now() + mainThreadExecutionTimeout)
318
+ let waitResult = semaphore.wait(timeout: .now() + timeout)
251
319
  if waitResult == .timedOut {
252
- // The main queue work may still be running; we stop waiting and report timeout.
253
- throw NSError(
254
- domain: RunnerErrorDomain.general,
255
- code: RunnerErrorCode.mainThreadExecutionTimedOut,
256
- userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
257
- )
320
+ mainThreadWorkLock.lock()
321
+ let stillRunning = !workState.finished
322
+ if stillRunning {
323
+ workState.abandoned = true
324
+ abandonedMainThreadWorkCount += 1
325
+ if abandonedMainThreadWorkSince == nil {
326
+ abandonedMainThreadWorkSince = Date()
327
+ }
328
+ onAbandoned?()
329
+ }
330
+ mainThreadWorkLock.unlock()
331
+ throw timeoutError()
258
332
  }
259
333
  switch result {
260
- case .success(let response):
261
- return response
334
+ case .success(let value):
335
+ return value
262
336
  case .failure(let error):
263
337
  throw error
264
338
  case .none:
@@ -270,6 +344,14 @@ extension RunnerTests {
270
344
  }
271
345
  }
272
346
 
347
+ private func mainThreadExecutionTimeoutError() -> Error {
348
+ NSError(
349
+ domain: RunnerErrorDomain.general,
350
+ code: RunnerErrorCode.mainThreadExecutionTimedOut,
351
+ userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
352
+ )
353
+ }
354
+
273
355
  // MARK: - Command Handling
274
356
 
275
357
  private func executeOnMainSafely(command: Command) throws -> Response {
@@ -333,68 +415,171 @@ extension RunnerTests {
333
415
  }
334
416
  }
335
417
 
336
- private func executeOnMain(command: Command) throws -> Response {
337
- var activeApp = currentApp ?? app
338
- if shouldSkipAppActivationPreflight(command) {
339
- activeApp = resolveAppWithoutActivation(command: command)
340
- } else if !isRunnerLifecycleCommand(command.command) {
341
- let normalizedBundleId = command.appBundleId?
342
- .trimmingCharacters(in: .whitespacesAndNewlines)
343
- let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
344
- if let bundleId = requestedBundleId {
345
- if currentBundleId != bundleId || currentApp == nil {
346
- _ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
418
+ private func executeSnapshotDispatched(command: Command) throws -> Response {
419
+ var hasRetried = false
420
+ while true {
421
+ let failureCountBefore = try runMainThreadWork(
422
+ command: command,
423
+ timeout: mainThreadExecutionTimeout,
424
+ timeoutError: mainThreadExecutionTimeoutError
425
+ ) {
426
+ self.currentXCTestFailureCount()
427
+ }
428
+ let response = try executeSnapshotDispatchedOnce(command: command)
429
+ let recordedFailureResponse = try runMainThreadWork(
430
+ command: command,
431
+ timeout: mainThreadExecutionTimeout,
432
+ timeoutError: mainThreadExecutionTimeoutError
433
+ ) {
434
+ self.didRecordXCTestFailure(since: failureCountBefore)
435
+ ? self.xctestRecordedFailureResponse(command: command, response: response)
436
+ : nil
437
+ }
438
+ if let recordedFailureResponse {
439
+ try runMainThreadWork(
440
+ command: command,
441
+ timeout: mainThreadExecutionTimeout,
442
+ timeoutError: mainThreadExecutionTimeoutError
443
+ ) {
444
+ self.invalidateCachedTarget(reason: "xctest_recorded_failure")
347
445
  }
348
- } else {
349
- // Do not reuse stale bundle targets when the caller does not explicitly request one.
350
- currentApp = nil
351
- currentBundleId = nil
446
+ return recordedFailureResponse
352
447
  }
353
-
354
- activeApp = currentApp ?? app
355
- if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
356
- activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
357
- } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
358
- ensureRunnerHostAppActive(reason: "missing_app_bundle")
359
- activeApp = app
448
+ if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
449
+ NSLog(
450
+ "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
451
+ command.command.rawValue
452
+ )
453
+ hasRetried = true
454
+ try runMainThreadWork(
455
+ command: command,
456
+ timeout: mainThreadExecutionTimeout,
457
+ timeoutError: mainThreadExecutionTimeoutError
458
+ ) {
459
+ self.invalidateCachedTarget(reason: "response_unavailable")
460
+ self.sleepFor(self.retryCooldown)
461
+ }
462
+ continue
360
463
  }
464
+ return response
465
+ }
466
+ }
361
467
 
362
- let skipExistenceWait = canUseFastForegroundAppGuard(
363
- activeApp: activeApp,
364
- requestedBundleId: requestedBundleId,
365
- command: command.command
468
+ private func executeSnapshotDispatchedOnce(command: Command) throws -> Response {
469
+ let preparation = try runMainThreadWork(
470
+ command: command,
471
+ timeout: mainThreadExecutionTimeout,
472
+ timeoutError: mainThreadExecutionTimeoutError
473
+ ) {
474
+ try self.prepareActiveCommandContextSafely(command: command)
475
+ }
476
+ switch preparation {
477
+ case .response(let response):
478
+ return response
479
+ case .context(let context):
480
+ return try executeSnapshotPrepared(command: command, activeApp: context.app)
481
+ }
482
+ }
483
+
484
+ private func executeSnapshotPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
485
+ let options = SnapshotOptions(
486
+ interactiveOnly: command.interactiveOnly ?? false,
487
+ depth: command.depth,
488
+ scope: command.scope,
489
+ raw: command.raw ?? false
490
+ )
491
+ do {
492
+ let payload: DataPayload
493
+ if options.raw {
494
+ payload = try snapshotRaw(app: activeApp, options: options)
495
+ } else {
496
+ payload = try snapshotFast(app: activeApp, options: options)
497
+ }
498
+ setNeedsPostSnapshotInteractionDelay()
499
+ return Response(ok: true, data: payload)
500
+ } catch let failure as SnapshotCaptureFailure {
501
+ invalidateCachedTargetAfterSnapshotFailure()
502
+ return Response(
503
+ ok: false,
504
+ error: ErrorPayload(
505
+ code: failure.code,
506
+ message: failure.message,
507
+ hint: failure.hint
508
+ )
366
509
  )
367
- if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
368
- if let bundleId = requestedBundleId {
369
- activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
370
- guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
371
- return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
372
- }
373
- } else {
374
- return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
375
- }
510
+ }
511
+ }
512
+
513
+ private func setNeedsPostSnapshotInteractionDelay() {
514
+ if Thread.isMainThread {
515
+ needsPostSnapshotInteractionDelay = true
516
+ return
517
+ }
518
+ guard !hasAbandonedTreeCapture() else {
519
+ NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED")
520
+ return
521
+ }
522
+ do {
523
+ try runMainThreadWork(
524
+ command: nil,
525
+ timeout: 1,
526
+ timeoutError: mainThreadExecutionTimeoutError
527
+ ) {
528
+ self.needsPostSnapshotInteractionDelay = true
376
529
  }
530
+ } catch {
531
+ NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error))
532
+ }
533
+ }
377
534
 
378
- if isInteractionCommand(command.command) {
379
- if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
380
- activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
381
- } else if requestedBundleId == nil, activeApp.state != .runningForeground {
382
- ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
383
- activeApp = app
384
- }
385
- let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
386
- activeApp: activeApp,
387
- requestedBundleId: requestedBundleId,
388
- command: command.command
389
- )
390
- if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
391
- if let bundleId = requestedBundleId {
392
- return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
393
- }
394
- return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
395
- }
396
- applyInteractionStabilizationIfNeeded()
535
+ private func invalidateCachedTargetAfterSnapshotFailure() {
536
+ if Thread.isMainThread {
537
+ invalidateCachedTarget(reason: "ax_snapshot_failure")
538
+ return
539
+ }
540
+ do {
541
+ try runMainThreadWork(
542
+ command: nil,
543
+ timeout: 1,
544
+ timeoutError: mainThreadExecutionTimeoutError
545
+ ) {
546
+ self.invalidateCachedTarget(reason: "ax_snapshot_failure")
397
547
  }
548
+ } catch {
549
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_FAILED=%@", String(describing: error))
550
+ }
551
+ }
552
+
553
+ private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation {
554
+ var preparation: ActiveCommandPreparation?
555
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
556
+ preparation = self.prepareActiveCommandContext(command: command)
557
+ })
558
+ if let exceptionMessage {
559
+ throw NSError(
560
+ domain: RunnerErrorDomain.exception,
561
+ code: RunnerErrorCode.objcException,
562
+ userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
563
+ )
564
+ }
565
+ guard let preparation else {
566
+ throw NSError(
567
+ domain: RunnerErrorDomain.general,
568
+ code: RunnerErrorCode.commandReturnedNoResponse,
569
+ userInfo: [NSLocalizedDescriptionKey: "snapshot preflight returned no response"]
570
+ )
571
+ }
572
+ return preparation
573
+ }
574
+
575
+ private func executeOnMain(command: Command) throws -> Response {
576
+ let preparation = prepareActiveCommandContext(command: command)
577
+ let activeApp: XCUIApplication
578
+ switch preparation {
579
+ case .response(let response):
580
+ return response
581
+ case .context(let context):
582
+ activeApp = context.app
398
583
  }
399
584
 
400
585
  switch command.command {
@@ -464,6 +649,89 @@ extension RunnerTests {
464
649
  }
465
650
  case .uptime:
466
651
  return executeUptime()
652
+ default:
653
+ break
654
+ }
655
+ return try executeOnMainPrepared(command: command, activeApp: activeApp)
656
+ }
657
+
658
+ private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation {
659
+ var activeApp = currentApp ?? app
660
+ if shouldSkipAppActivationPreflight(command) {
661
+ activeApp = resolveAppWithoutActivation(command: command)
662
+ } else if !isRunnerLifecycleCommand(command.command) {
663
+ let normalizedBundleId = command.appBundleId?
664
+ .trimmingCharacters(in: .whitespacesAndNewlines)
665
+ let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
666
+ if let bundleId = requestedBundleId {
667
+ if currentBundleId != bundleId || currentApp == nil {
668
+ _ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
669
+ }
670
+ } else {
671
+ // Do not reuse stale bundle targets when the caller does not explicitly request one.
672
+ currentApp = nil
673
+ currentBundleId = nil
674
+ }
675
+
676
+ activeApp = currentApp ?? app
677
+ if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
678
+ activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
679
+ } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
680
+ ensureRunnerHostAppActive(reason: "missing_app_bundle")
681
+ activeApp = app
682
+ }
683
+
684
+ let skipExistenceWait = canUseFastForegroundAppGuard(
685
+ activeApp: activeApp,
686
+ requestedBundleId: requestedBundleId,
687
+ command: command.command
688
+ )
689
+ if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
690
+ if let bundleId = requestedBundleId {
691
+ activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
692
+ guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
693
+ return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
694
+ }
695
+ } else {
696
+ return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
697
+ }
698
+ }
699
+
700
+ if isInteractionCommand(command.command) {
701
+ if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
702
+ activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
703
+ } else if requestedBundleId == nil, activeApp.state != .runningForeground {
704
+ ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
705
+ activeApp = app
706
+ }
707
+ let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
708
+ activeApp: activeApp,
709
+ requestedBundleId: requestedBundleId,
710
+ command: command.command
711
+ )
712
+ if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
713
+ if let bundleId = requestedBundleId {
714
+ return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
715
+ }
716
+ return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
717
+ }
718
+ applyInteractionStabilizationIfNeeded()
719
+ }
720
+ }
721
+ return .context(ActiveCommandContext(app: activeApp))
722
+ }
723
+
724
+ private func executeOnMainPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
725
+ var activeApp = activeApp
726
+ switch command.command {
727
+ case .status, .shutdown, .recordStart, .recordStop, .uptime:
728
+ return Response(
729
+ ok: false,
730
+ error: ErrorPayload(
731
+ code: "UNSUPPORTED_OPERATION",
732
+ message: "\(command.command.rawValue) cannot be executed through the prepared command path"
733
+ )
734
+ )
467
735
  case .tap:
468
736
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
469
737
  let match = findElement(
@@ -477,6 +745,24 @@ extension RunnerTests {
477
745
  }
478
746
  if let element = match.element {
479
747
  let frame = element.frame
748
+ // XCTest reports closed-drawer/off-viewport items as hittable, then
749
+ // "taps" coordinates outside the visible window as a silent no-op.
750
+ // Refuse instead; the daemon falls back to tree-based resolution,
751
+ // which can prefer an on-screen candidate or explain the off-screen
752
+ // state. The check uses the main window frame, not app.frame: on RN
753
+ // apps app.frame unions transformed subtrees (a closed drawer at
754
+ // negative x), so it happily "contains" unreachable coordinates.
755
+ // The DECISION lives in TapPointPolicy (golden parity table with the
756
+ // TS twin); onScreenWindowFrame only supplies the frame.
757
+ if !match.usedNonHittableFallback
758
+ && !TapPointPolicy.isAllowed(
759
+ elementFrame: frame,
760
+ windowFrame: onScreenWindowFrame(app: activeApp)
761
+ ) {
762
+ return Response(ok: false, error: ErrorPayload(
763
+ code: "ELEMENT_OFFSCREEN",
764
+ message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
765
+ }
480
766
  let isTextEntry = isTextEntryElement(element)
481
767
  let touchFrame = frame.isEmpty
482
768
  ? nil
@@ -504,27 +790,19 @@ extension RunnerTests {
504
790
  }
505
791
  return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
506
792
  }
507
- if let text = command.text {
508
- if let element = findElement(app: activeApp, text: text) {
509
- let (timing, outcome) = performGesture(activeApp) {
510
- activateElement(app: activeApp, element: element, action: "tap by text")
511
- }
512
- if let response = unsupportedResponse(for: outcome) {
513
- return response
514
- }
515
- return gestureResponse(message: "tapped", timing: timing)
516
- }
517
- return Response(ok: false, error: ErrorPayload(message: "element not found"))
518
- }
519
793
  if let x = command.x, let y = command.y {
520
794
  var fallback: GestureFallback?
521
795
  if command.synthesized == true {
796
+ let policyKind = SynthesizedGesturePolicyKind.coordinateTap
797
+ let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(policyKind))
522
798
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
523
- synthesizedTapAt(app: activeApp, x: x, y: y)
799
+ synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
524
800
  }
525
801
  if case .performed = outcome {
802
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
526
803
  return gestureResponse(message: "tapped", timing: timing)
527
804
  }
805
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
528
806
  fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
529
807
  }
530
808
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
@@ -539,7 +817,7 @@ extension RunnerTests {
539
817
  fallback: fallback
540
818
  )
541
819
  }
542
- return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
820
+ return Response(ok: false, error: ErrorPayload(message: "tap requires a selector or x/y"))
543
821
  case .mouseClick:
544
822
  guard let x = command.x, let y = command.y else {
545
823
  return Response(ok: false, error: ErrorPayload(message: "mouseClick requires x and y"))
@@ -588,12 +866,13 @@ extension RunnerTests {
588
866
  y2: y2,
589
867
  durationMs: command.durationMs,
590
868
  synthesized: command.synthesized == true,
591
- message: "dragged"
869
+ message: "dragged",
870
+ synthesizedPolicyKind: .synthesizedDrag
592
871
  )
593
872
  case .scroll:
594
- // Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame via
595
- // resolvedTouchReferenceFrame, computes drag endpoints with the Swift port of
596
- // buildScrollGesturePlan, then runs the same non-synthesized drag path scroll's drag used.
873
+ // Fused frame-resolve + drag scroll for non-tvOS. On iOS this intentionally stays on the
874
+ // AX-free synthesized coordinate lane so scroll keeps working when XCTest cannot serialize
875
+ // the accessibility tree.
597
876
  guard let direction = command.direction,
598
877
  direction == "up" || direction == "down" || direction == "left" || direction == "right"
599
878
  else {
@@ -605,7 +884,14 @@ extension RunnerTests {
605
884
  )
606
885
  )
607
886
  }
608
- let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
887
+ let scrollPolicyKind = SynthesizedGesturePolicyKind.scroll
888
+ guard let scrollContext = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(scrollPolicyKind)) else {
889
+ return Response(
890
+ ok: false,
891
+ error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
892
+ )
893
+ }
894
+ let frame = scrollReferenceFrame(app: activeApp, context: scrollContext)
609
895
  guard frame.width > 0, frame.height > 0 else {
610
896
  return Response(
611
897
  ok: false,
@@ -645,8 +931,10 @@ extension RunnerTests {
645
931
  x2: frame.minX + plan.x2,
646
932
  y2: frame.minY + plan.y2,
647
933
  durationMs: command.durationMs,
648
- synthesized: command.durationMs != nil,
649
- message: "scrolled"
934
+ synthesized: shouldUseSynthesizedScrollPath(),
935
+ message: "scrolled",
936
+ synthesizedContext: scrollContext.withReferenceFrame(frame),
937
+ synthesizedPolicyKind: scrollPolicyKind
650
938
  )
651
939
  case .desktopScroll:
652
940
  guard let direction = command.direction,
@@ -780,33 +1068,7 @@ extension RunnerTests {
780
1068
  }
781
1069
  return Response(ok: true, data: DataPayload(text: text))
782
1070
  case .snapshot:
783
- let options = SnapshotOptions(
784
- interactiveOnly: command.interactiveOnly ?? false,
785
- depth: command.depth,
786
- scope: command.scope,
787
- raw: command.raw ?? false
788
- )
789
- do {
790
- let payload: DataPayload
791
- if options.raw {
792
- payload = try snapshotRaw(app: activeApp, options: options)
793
- } else {
794
- payload = try snapshotFast(app: activeApp, options: options)
795
- }
796
- needsPostSnapshotInteractionDelay = true
797
- return Response(ok: true, data: payload)
798
- } catch let failure as SnapshotCaptureFailure {
799
- invalidateCachedTarget(reason: "ax_snapshot_failure")
800
- // Other thrown errors fall through to executeOnMainSafely's generic error response.
801
- return Response(
802
- ok: false,
803
- error: ErrorPayload(
804
- code: failure.code,
805
- message: failure.message,
806
- hint: failure.hint
807
- )
808
- )
809
- }
1071
+ return try executeSnapshotPrepared(command: command, activeApp: activeApp)
810
1072
  case .screenshot:
811
1073
  let screenshot: XCUIScreenshot
812
1074
  #if os(macOS)
@@ -998,10 +1260,9 @@ extension RunnerTests {
998
1260
  }
999
1261
  }
1000
1262
 
1001
- /// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
1002
- /// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
1003
- /// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
1004
- /// gestureResponse(.drag). `.scroll` uses the synthesized path only when a duration is requested.
1263
+ /// Shared drag execution for `.drag` and the fused `.scroll`. The iOS synthesized lane keeps
1264
+ /// each command's fallback policy explicit: scroll requires private synthesis, while explicit
1265
+ /// synthesized drag can still use the coordinate fallback unless AX is known unavailable.
1005
1266
  private func executeDragGesture(
1006
1267
  activeApp: XCUIApplication,
1007
1268
  x: Double,
@@ -1010,8 +1271,30 @@ extension RunnerTests {
1010
1271
  y2: Double,
1011
1272
  durationMs: Double?,
1012
1273
  synthesized: Bool,
1013
- message: String
1274
+ message: String,
1275
+ synthesizedContext: SynthesizedCoordinateContext? = nil,
1276
+ synthesizedPolicyKind: SynthesizedGesturePolicyKind
1014
1277
  ) -> Response {
1278
+ let commandName = dragCommandName(message: message)
1279
+ guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
1280
+ return Response(
1281
+ ok: false,
1282
+ error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates")
1283
+ )
1284
+ }
1285
+ if synthesized, let synthesizedResponse = executeSynthesizedDragGesture(
1286
+ activeApp: activeApp,
1287
+ x: x,
1288
+ y: y,
1289
+ x2: x2,
1290
+ y2: y2,
1291
+ durationMs: durationMs,
1292
+ message: message,
1293
+ context: synthesizedContext,
1294
+ policyKind: synthesizedPolicyKind
1295
+ ) {
1296
+ return synthesizedResponse
1297
+ }
1015
1298
  let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
1016
1299
  let dragFrame = resolvedDragVisualizationFrame(
1017
1300
  app: activeApp,
@@ -1023,6 +1306,7 @@ extension RunnerTests {
1023
1306
  var fallback: GestureFallback?
1024
1307
  if synthesized {
1025
1308
  let durationMs = min(max(durationMs ?? 250, 16), 10000)
1309
+ let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(synthesizedPolicyKind))
1026
1310
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1027
1311
  synthesizedDragAt(
1028
1312
  app: activeApp,
@@ -1030,7 +1314,8 @@ extension RunnerTests {
1030
1314
  y: dragPoints.y,
1031
1315
  x2: dragPoints.x2,
1032
1316
  y2: dragPoints.y2,
1033
- durationMs: durationMs
1317
+ durationMs: durationMs,
1318
+ context: context
1034
1319
  )
1035
1320
  }
1036
1321
  if case .performed = outcome {
@@ -1062,6 +1347,154 @@ extension RunnerTests {
1062
1347
  )
1063
1348
  }
1064
1349
 
1350
+ private func executeSynthesizedDragGesture(
1351
+ activeApp: XCUIApplication,
1352
+ x: Double,
1353
+ y: Double,
1354
+ x2: Double,
1355
+ y2: Double,
1356
+ durationMs: Double?,
1357
+ message: String,
1358
+ context: SynthesizedCoordinateContext?,
1359
+ policyKind: SynthesizedGesturePolicyKind
1360
+ ) -> Response? {
1361
+ #if os(iOS)
1362
+ let policy = synthesizedGesturePolicy(policyKind)
1363
+ let context = context ?? synthesizedCoordinateContext(policy: policy)
1364
+ guard let plan = axFreeSynthesizedDragPlan(
1365
+ app: activeApp,
1366
+ x: x,
1367
+ y: y,
1368
+ x2: x2,
1369
+ y2: y2,
1370
+ context: context
1371
+ )
1372
+ else {
1373
+ if context?.allowsXCTestCoordinateFallback == true {
1374
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
1375
+ return executeCoordinateDragFallback(
1376
+ activeApp: activeApp,
1377
+ x: x,
1378
+ y: y,
1379
+ x2: x2,
1380
+ y2: y2,
1381
+ durationMs: durationMs,
1382
+ message: message,
1383
+ fallback: nil
1384
+ )
1385
+ }
1386
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
1387
+ return Response(
1388
+ ok: false,
1389
+ error: ErrorPayload(
1390
+ code: "INVALID_ARGS",
1391
+ message: "\(dragCommandName(message: message)) could not resolve a finite synthesized coordinate frame"
1392
+ )
1393
+ )
1394
+ }
1395
+ let durationMs = min(max(durationMs ?? 250, 16), 10000)
1396
+ let dragFrame = axFreeDragVisualizationFrame(
1397
+ x: plan.points.x,
1398
+ y: plan.points.y,
1399
+ x2: plan.points.x2,
1400
+ y2: plan.points.y2,
1401
+ referenceFrame: plan.referenceFrame
1402
+ )
1403
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1404
+ synthesizedDragAt(
1405
+ app: activeApp,
1406
+ x: plan.points.x,
1407
+ y: plan.points.y,
1408
+ x2: plan.points.x2,
1409
+ y2: plan.points.y2,
1410
+ durationMs: durationMs,
1411
+ context: plan.context
1412
+ )
1413
+ }
1414
+ if case .performed = outcome {
1415
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
1416
+ return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
1417
+ }
1418
+ if plan.context.allowsXCTestCoordinateFallback {
1419
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: true)
1420
+ return executeCoordinateDragFallback(
1421
+ activeApp: activeApp,
1422
+ x: plan.points.x,
1423
+ y: plan.points.y,
1424
+ x2: plan.points.x2,
1425
+ y2: plan.points.y2,
1426
+ durationMs: durationMs,
1427
+ message: message,
1428
+ fallback: gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
1429
+ )
1430
+ }
1431
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
1432
+ return unsupportedResponse(for: outcome)
1433
+ #else
1434
+ return nil
1435
+ #endif
1436
+ }
1437
+
1438
+ private func executeCoordinateDragFallback(
1439
+ activeApp: XCUIApplication,
1440
+ x: Double,
1441
+ y: Double,
1442
+ x2: Double,
1443
+ y2: Double,
1444
+ durationMs: Double?,
1445
+ message: String,
1446
+ fallback: GestureFallback?
1447
+ ) -> Response {
1448
+ let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
1449
+ let dragFrame = resolvedDragVisualizationFrame(
1450
+ app: activeApp,
1451
+ x: dragPoints.x,
1452
+ y: dragPoints.y,
1453
+ x2: dragPoints.x2,
1454
+ y2: dragPoints.y2
1455
+ )
1456
+ let holdDuration = synthesizedSwipeFallbackHoldDuration(durationMs: durationMs ?? 250)
1457
+ let (timing, outcome) = performGesture(activeApp) {
1458
+ dragAt(
1459
+ app: activeApp,
1460
+ x: dragPoints.x,
1461
+ y: dragPoints.y,
1462
+ x2: dragPoints.x2,
1463
+ y2: dragPoints.y2,
1464
+ holdDuration: holdDuration
1465
+ )
1466
+ }
1467
+ if let response = unsupportedResponse(for: outcome) {
1468
+ return response
1469
+ }
1470
+ return gestureResponse(
1471
+ message: message,
1472
+ timing: timing,
1473
+ frame: .drag(dragFrame),
1474
+ fallback: fallback
1475
+ )
1476
+ }
1477
+
1478
+ private func scrollReferenceFrame(app: XCUIApplication, context: SynthesizedCoordinateContext) -> CGRect {
1479
+ #if os(iOS)
1480
+ return synthesizedFrameAvoidingKeyboardWhenAllowed(app: app, context: context)
1481
+ #else
1482
+ return resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
1483
+ #endif
1484
+ }
1485
+
1486
+ private func shouldUseSynthesizedScrollPath() -> Bool {
1487
+ #if os(iOS)
1488
+ return true
1489
+ #else
1490
+ return false
1491
+ #endif
1492
+ }
1493
+
1494
+ private func dragCommandName(message: String) -> String {
1495
+ return message == "scrolled" ? "scroll" : "drag"
1496
+ }
1497
+
1065
1498
  private func currentXCTestFailureCount() -> Int {
1066
1499
  return testRun?.failureCount ?? 0
1067
1500
  }
@@ -1094,20 +1527,31 @@ extension RunnerTests {
1094
1527
 
1095
1528
  private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
1096
1529
  #if os(iOS)
1097
- // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not need
1098
- // app activation, window lookup, keyboard lookup, or element resolution. Selector/text taps
1099
- // intentionally stay on the normal AX path because they need an element query.
1530
+ // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not
1531
+ // need app activation, window lookup, keyboard lookup, or element resolution. Selector/text
1532
+ // interactions intentionally stay on the normal AX path because they need an element query.
1533
+ // Scroll/drag/sequence keep the normal foreground guard and stabilization path.
1534
+ guard command.text == nil, command.selectorKey == nil else { return false }
1535
+ guard hasCachedTargetForActivationSkip(command: command) else { return false }
1100
1536
  return command.command == .tap
1101
1537
  && command.synthesized == true
1102
1538
  && command.x != nil
1103
1539
  && command.y != nil
1104
- && command.text == nil
1105
- && command.selectorKey == nil
1106
1540
  #else
1107
1541
  return false
1108
1542
  #endif
1109
1543
  }
1110
1544
 
1545
+ private func hasCachedTargetForActivationSkip(command: Command) -> Bool {
1546
+ guard currentApp != nil else { return false }
1547
+ guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
1548
+ !bundleId.isEmpty
1549
+ else {
1550
+ return true
1551
+ }
1552
+ return currentBundleId == bundleId
1553
+ }
1554
+
1111
1555
  private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
1112
1556
  guard let bundleId = command.appBundleId?
1113
1557
  .trimmingCharacters(in: .whitespacesAndNewlines),
@@ -1128,6 +1572,7 @@ extension RunnerTests {
1128
1572
  let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
1129
1573
  let textEntryMode = resolveTextEntryMode(command)
1130
1574
  let target: TextEntryTarget
1575
+ let focusStartedAt = Date()
1131
1576
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
1132
1577
  let match = findElement(
1133
1578
  app: activeApp,
@@ -1148,6 +1593,13 @@ extension RunnerTests {
1148
1593
  } else {
1149
1594
  target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
1150
1595
  }
1596
+ NSLog(
1597
+ "AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=focus durationMs=%.1f chars=%d mode=%@",
1598
+ command.commandId ?? "",
1599
+ Date().timeIntervalSince(focusStartedAt) * 1000.0,
1600
+ text.count,
1601
+ textEntryModeName(textEntryMode)
1602
+ )
1151
1603
  if textEntryMode == .replacement {
1152
1604
  guard target.element != nil else {
1153
1605
  let message =
@@ -1162,7 +1614,8 @@ extension RunnerTests {
1162
1614
  target: target,
1163
1615
  text: text,
1164
1616
  delaySeconds: delaySeconds,
1165
- repairMode: textEntryMode
1617
+ repairMode: textEntryMode,
1618
+ commandId: command.commandId
1166
1619
  )
1167
1620
  if textResult.verified == false {
1168
1621
  let expected = textResult.expectedText ?? ""