agent-device 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +5 -1
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.apk → agent-device-android-multitouch-helper-0.19.1.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.1.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.manifest.json → agent-device-android-multitouch-helper-0.19.1.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.apk → agent-device-android-snapshot-helper-0.19.1.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.1.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
  8. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +24 -7
  9. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +506 -119
  10. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +153 -48
  11. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
  12. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -5
  13. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +60 -52
  14. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +159 -56
  15. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
  16. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -14
  17. package/dist/src/android-adb.d.ts +1 -2
  18. package/dist/src/android-adb.js +1 -1
  19. package/dist/src/android-input-ownership.js +6 -0
  20. package/dist/src/android.js +1 -1
  21. package/dist/src/app-lifecycle.js +3 -3
  22. package/dist/src/apple-runner-options.js +1 -1
  23. package/dist/src/apps.js +5 -5
  24. package/dist/src/cli-help.js +131 -34
  25. package/dist/src/cli-runner.js +1 -1
  26. package/dist/src/cli.js +11 -11
  27. package/dist/src/client.js +2 -2
  28. package/dist/src/command-metadata.js +1 -1
  29. package/dist/src/command-schema.js +3 -3
  30. package/dist/src/device-input-state.js +2 -2
  31. package/dist/src/devices2.js +1 -1
  32. package/dist/src/find.js +1 -1
  33. package/dist/src/generic.js +1 -1
  34. package/dist/src/index.d.ts +199 -20
  35. package/dist/src/input-actions.js +3 -3
  36. package/dist/src/interaction-snapshot.js +1 -1
  37. package/dist/src/interaction.js +1 -1
  38. package/dist/src/interactor.js +2 -2
  39. package/dist/src/internal/bin.js +1 -1
  40. package/dist/src/internal/daemon.js +17 -15
  41. package/dist/src/lease.js +1 -1
  42. package/dist/src/linux.js +1 -1
  43. package/dist/src/metro.d.ts +0 -1
  44. package/dist/src/notifications.js +1 -1
  45. package/dist/src/points.js +1 -1
  46. package/dist/src/project-runtime.js +1 -1
  47. package/dist/src/providers.js +1 -1
  48. package/dist/src/react-native.js +1 -1
  49. package/dist/src/record-trace-recording.js +2 -2
  50. package/dist/src/record-trace.js +1 -1
  51. package/dist/src/registry.js +24 -23
  52. package/dist/src/reporting.js +3 -3
  53. package/dist/src/runner-client.js +1 -1
  54. package/dist/src/runtime.js +2 -2
  55. package/dist/src/sdk-android-adb.d.ts +2 -2
  56. package/dist/src/sdk-batch-runner.d.ts +0 -1
  57. package/dist/src/sdk-batch.js +2 -2
  58. package/dist/src/sdk-finders.d.ts +1 -2
  59. package/dist/src/sdk-finders.js +1 -1
  60. package/dist/src/sdk-io.d.ts +0 -1
  61. package/dist/src/sdk-remote-config.d.ts +1 -2
  62. package/dist/src/sdk-remote-config.js +1 -1
  63. package/dist/src/sdk-selectors.js +1 -1
  64. package/dist/src/selector-runtime.js +1 -1
  65. package/dist/src/selectors.d.ts +0 -1
  66. package/dist/src/selectors.js +1 -1
  67. package/dist/src/selectors2.js +1 -1
  68. package/dist/src/server.js +3 -3
  69. package/dist/src/session.js +10 -10
  70. package/dist/src/simctl.js +1 -1
  71. package/dist/src/simulator.js +1 -1
  72. package/dist/src/snapshot-diagnostics.js +2 -2
  73. package/dist/src/snapshot-helper.js +4 -4
  74. package/dist/src/snapshot-quality.js +1 -1
  75. package/dist/src/snapshot2.js +2 -2
  76. package/dist/src/tv-remote.js +14 -0
  77. package/dist/src/web.js +1 -1
  78. package/package.json +5 -4
  79. package/server.json +2 -2
  80. package/skills/agent-device/SKILL.md +8 -7
  81. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.0.apk.sha256 +0 -1
  82. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.0.apk.sha256 +0 -1
  83. package/dist/src/device-rotation.js +0 -14
  84. package/dist/src/logcat.js +0 -6
@@ -188,6 +188,15 @@ extension RunnerTests {
188
188
  var abandoned = false
189
189
  }
190
190
 
191
+ struct ActiveCommandContext {
192
+ let app: XCUIApplication
193
+ }
194
+
195
+ enum ActiveCommandPreparation {
196
+ case response(Response)
197
+ case context(ActiveCommandContext)
198
+ }
199
+
191
200
  enum MainThreadBusyState {
192
201
  case idle
193
202
  case busy(abandonedForSeconds: TimeInterval)
@@ -259,12 +268,35 @@ extension RunnerTests {
259
268
  if Thread.isMainThread {
260
269
  return try executeOnMainSafely(command: command)
261
270
  }
262
- 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>?
263
295
  let semaphore = DispatchSemaphore(value: 0)
264
296
  let workState = MainThreadWorkState()
265
297
  DispatchQueue.main.async {
266
298
  do {
267
- result = .success(try self.executeOnMainSafely(command: command))
299
+ result = .success(try work())
268
300
  } catch {
269
301
  result = .failure(error)
270
302
  }
@@ -275,15 +307,16 @@ extension RunnerTests {
275
307
  self.abandonedMainThreadWorkSince = nil
276
308
  NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED")
277
309
  }
310
+ self.mainThreadWorkLock.unlock()
311
+ onDrained?()
278
312
  } else {
279
313
  workState.finished = true
314
+ self.mainThreadWorkLock.unlock()
280
315
  }
281
- self.mainThreadWorkLock.unlock()
282
316
  semaphore.signal()
283
317
  }
284
- let waitResult = semaphore.wait(timeout: .now() + mainThreadExecutionTimeout)
318
+ let waitResult = semaphore.wait(timeout: .now() + timeout)
285
319
  if waitResult == .timedOut {
286
- // The main queue work may still be running; we stop waiting and report timeout.
287
320
  mainThreadWorkLock.lock()
288
321
  let stillRunning = !workState.finished
289
322
  if stillRunning {
@@ -292,24 +325,14 @@ extension RunnerTests {
292
325
  if abandonedMainThreadWorkSince == nil {
293
326
  abandonedMainThreadWorkSince = Date()
294
327
  }
328
+ onAbandoned?()
295
329
  }
296
330
  mainThreadWorkLock.unlock()
297
- if stillRunning && command.command == .snapshot {
298
- // The next capture on this screen must not re-grind the tree backend.
299
- penalizeSnapshotTreeBackend(
300
- bundleId: command.appBundleId,
301
- reason: "main_thread_watchdog"
302
- )
303
- }
304
- throw NSError(
305
- domain: RunnerErrorDomain.general,
306
- code: RunnerErrorCode.mainThreadExecutionTimedOut,
307
- userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
308
- )
331
+ throw timeoutError()
309
332
  }
310
333
  switch result {
311
- case .success(let response):
312
- return response
334
+ case .success(let value):
335
+ return value
313
336
  case .failure(let error):
314
337
  throw error
315
338
  case .none:
@@ -321,6 +344,14 @@ extension RunnerTests {
321
344
  }
322
345
  }
323
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
+
324
355
  // MARK: - Command Handling
325
356
 
326
357
  private func executeOnMainSafely(command: Command) throws -> Response {
@@ -384,68 +415,171 @@ extension RunnerTests {
384
415
  }
385
416
  }
386
417
 
387
- private func executeOnMain(command: Command) throws -> Response {
388
- var activeApp = currentApp ?? app
389
- if shouldSkipAppActivationPreflight(command) {
390
- activeApp = resolveAppWithoutActivation(command: command)
391
- } else if !isRunnerLifecycleCommand(command.command) {
392
- let normalizedBundleId = command.appBundleId?
393
- .trimmingCharacters(in: .whitespacesAndNewlines)
394
- let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
395
- if let bundleId = requestedBundleId {
396
- if currentBundleId != bundleId || currentApp == nil {
397
- _ = 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")
398
445
  }
399
- } else {
400
- // Do not reuse stale bundle targets when the caller does not explicitly request one.
401
- currentApp = nil
402
- currentBundleId = nil
446
+ return recordedFailureResponse
403
447
  }
404
-
405
- activeApp = currentApp ?? app
406
- if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
407
- activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
408
- } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
409
- ensureRunnerHostAppActive(reason: "missing_app_bundle")
410
- 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
411
463
  }
464
+ return response
465
+ }
466
+ }
412
467
 
413
- let skipExistenceWait = canUseFastForegroundAppGuard(
414
- activeApp: activeApp,
415
- requestedBundleId: requestedBundleId,
416
- 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
+ )
417
509
  )
418
- if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
419
- if let bundleId = requestedBundleId {
420
- activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
421
- guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
422
- return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
423
- }
424
- } else {
425
- return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
426
- }
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
427
529
  }
530
+ } catch {
531
+ NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error))
532
+ }
533
+ }
428
534
 
429
- if isInteractionCommand(command.command) {
430
- if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
431
- activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
432
- } else if requestedBundleId == nil, activeApp.state != .runningForeground {
433
- ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
434
- activeApp = app
435
- }
436
- let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
437
- activeApp: activeApp,
438
- requestedBundleId: requestedBundleId,
439
- command: command.command
440
- )
441
- if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
442
- if let bundleId = requestedBundleId {
443
- return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
444
- }
445
- return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
446
- }
447
- 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")
448
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
449
583
  }
450
584
 
451
585
  switch command.command {
@@ -515,6 +649,89 @@ extension RunnerTests {
515
649
  }
516
650
  case .uptime:
517
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
+ )
518
735
  case .tap:
519
736
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
520
737
  let match = findElement(
@@ -576,12 +793,16 @@ extension RunnerTests {
576
793
  if let x = command.x, let y = command.y {
577
794
  var fallback: GestureFallback?
578
795
  if command.synthesized == true {
796
+ let policyKind = SynthesizedGesturePolicyKind.coordinateTap
797
+ let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(policyKind))
579
798
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
580
- synthesizedTapAt(app: activeApp, x: x, y: y)
799
+ synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
581
800
  }
582
801
  if case .performed = outcome {
802
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
583
803
  return gestureResponse(message: "tapped", timing: timing)
584
804
  }
805
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
585
806
  fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
586
807
  }
587
808
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
@@ -645,12 +866,13 @@ extension RunnerTests {
645
866
  y2: y2,
646
867
  durationMs: command.durationMs,
647
868
  synthesized: command.synthesized == true,
648
- message: "dragged"
869
+ message: "dragged",
870
+ synthesizedPolicyKind: .synthesizedDrag
649
871
  )
650
872
  case .scroll:
651
- // Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame via
652
- // resolvedTouchReferenceFrame, computes drag endpoints with the Swift port of
653
- // 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.
654
876
  guard let direction = command.direction,
655
877
  direction == "up" || direction == "down" || direction == "left" || direction == "right"
656
878
  else {
@@ -662,7 +884,14 @@ extension RunnerTests {
662
884
  )
663
885
  )
664
886
  }
665
- 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)
666
895
  guard frame.width > 0, frame.height > 0 else {
667
896
  return Response(
668
897
  ok: false,
@@ -702,8 +931,10 @@ extension RunnerTests {
702
931
  x2: frame.minX + plan.x2,
703
932
  y2: frame.minY + plan.y2,
704
933
  durationMs: command.durationMs,
705
- synthesized: command.durationMs != nil,
706
- message: "scrolled"
934
+ synthesized: shouldUseSynthesizedScrollPath(),
935
+ message: "scrolled",
936
+ synthesizedContext: scrollContext.withReferenceFrame(frame),
937
+ synthesizedPolicyKind: scrollPolicyKind
707
938
  )
708
939
  case .desktopScroll:
709
940
  guard let direction = command.direction,
@@ -837,33 +1068,7 @@ extension RunnerTests {
837
1068
  }
838
1069
  return Response(ok: true, data: DataPayload(text: text))
839
1070
  case .snapshot:
840
- let options = SnapshotOptions(
841
- interactiveOnly: command.interactiveOnly ?? false,
842
- depth: command.depth,
843
- scope: command.scope,
844
- raw: command.raw ?? false
845
- )
846
- do {
847
- let payload: DataPayload
848
- if options.raw {
849
- payload = try snapshotRaw(app: activeApp, options: options)
850
- } else {
851
- payload = try snapshotFast(app: activeApp, options: options)
852
- }
853
- needsPostSnapshotInteractionDelay = true
854
- return Response(ok: true, data: payload)
855
- } catch let failure as SnapshotCaptureFailure {
856
- invalidateCachedTarget(reason: "ax_snapshot_failure")
857
- // Other thrown errors fall through to executeOnMainSafely's generic error response.
858
- return Response(
859
- ok: false,
860
- error: ErrorPayload(
861
- code: failure.code,
862
- message: failure.message,
863
- hint: failure.hint
864
- )
865
- )
866
- }
1071
+ return try executeSnapshotPrepared(command: command, activeApp: activeApp)
867
1072
  case .screenshot:
868
1073
  let screenshot: XCUIScreenshot
869
1074
  #if os(macOS)
@@ -1055,10 +1260,9 @@ extension RunnerTests {
1055
1260
  }
1056
1261
  }
1057
1262
 
1058
- /// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
1059
- /// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
1060
- /// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
1061
- /// 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.
1062
1266
  private func executeDragGesture(
1063
1267
  activeApp: XCUIApplication,
1064
1268
  x: Double,
@@ -1067,8 +1271,30 @@ extension RunnerTests {
1067
1271
  y2: Double,
1068
1272
  durationMs: Double?,
1069
1273
  synthesized: Bool,
1070
- message: String
1274
+ message: String,
1275
+ synthesizedContext: SynthesizedCoordinateContext? = nil,
1276
+ synthesizedPolicyKind: SynthesizedGesturePolicyKind
1071
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
+ }
1072
1298
  let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
1073
1299
  let dragFrame = resolvedDragVisualizationFrame(
1074
1300
  app: activeApp,
@@ -1080,6 +1306,7 @@ extension RunnerTests {
1080
1306
  var fallback: GestureFallback?
1081
1307
  if synthesized {
1082
1308
  let durationMs = min(max(durationMs ?? 250, 16), 10000)
1309
+ let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(synthesizedPolicyKind))
1083
1310
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1084
1311
  synthesizedDragAt(
1085
1312
  app: activeApp,
@@ -1087,7 +1314,8 @@ extension RunnerTests {
1087
1314
  y: dragPoints.y,
1088
1315
  x2: dragPoints.x2,
1089
1316
  y2: dragPoints.y2,
1090
- durationMs: durationMs
1317
+ durationMs: durationMs,
1318
+ context: context
1091
1319
  )
1092
1320
  }
1093
1321
  if case .performed = outcome {
@@ -1119,6 +1347,154 @@ extension RunnerTests {
1119
1347
  )
1120
1348
  }
1121
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
+
1122
1498
  private func currentXCTestFailureCount() -> Int {
1123
1499
  return testRun?.failureCount ?? 0
1124
1500
  }
@@ -1151,20 +1527,31 @@ extension RunnerTests {
1151
1527
 
1152
1528
  private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
1153
1529
  #if os(iOS)
1154
- // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not need
1155
- // app activation, window lookup, keyboard lookup, or element resolution. Selector/text taps
1156
- // 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 }
1157
1536
  return command.command == .tap
1158
1537
  && command.synthesized == true
1159
1538
  && command.x != nil
1160
1539
  && command.y != nil
1161
- && command.text == nil
1162
- && command.selectorKey == nil
1163
1540
  #else
1164
1541
  return false
1165
1542
  #endif
1166
1543
  }
1167
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
+
1168
1555
  private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
1169
1556
  guard let bundleId = command.appBundleId?
1170
1557
  .trimmingCharacters(in: .whitespacesAndNewlines),