agent-device 0.21.7 → 0.21.9

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 (204) hide show
  1. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.7.apk → agent-device-android-ime-helper-0.21.9.apk} +0 -0
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.9.apk.sha256 +1 -0
  3. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.7.manifest.json → agent-device-android-ime-helper-0.21.9.manifest.json} +4 -4
  4. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.21.7.apk → agent-device-android-snapshot-helper-0.21.9.apk} +0 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.9.apk.sha256 +1 -0
  6. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.21.7.manifest.json → agent-device-android-snapshot-helper-0.21.9.manifest.json} +6 -6
  7. package/apple/macos-helper/Package.swift +16 -1
  8. package/apple/macos-helper/Sources/AgentDeviceMacOSDeviceHub/DeviceHubPose.swift +47 -0
  9. package/apple/macos-helper/Sources/AgentDeviceMacOSDeviceHub/DeviceHubWindowInventory.swift +65 -0
  10. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubPose.swift +225 -0
  11. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubSidebar.swift +57 -0
  12. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubWindowDiscovery.swift +108 -0
  13. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +66 -13
  14. package/apple/macos-helper/Sources/AgentDeviceMacOSInput/MouseClickDelivery.swift +116 -0
  15. package/apple/macos-helper/Sources/AgentDeviceMacOSInput/MouseClickSchedule.swift +61 -0
  16. package/apple/macos-helper/Tests/AgentDeviceMacOSDeviceHubTests/DeviceHubPoseTests.swift +30 -0
  17. package/apple/macos-helper/Tests/AgentDeviceMacOSDeviceHubTests/DeviceHubWindowInventoryTests.swift +65 -0
  18. package/apple/macos-helper/Tests/AgentDeviceMacOSInputTests/MouseClickScheduleTests.swift +58 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift +175 -0
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +10 -2
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +42 -15
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +8 -0
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +45 -22
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +39 -734
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +20 -4
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +63 -1
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +13 -0
  29. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +25 -10
  30. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +5 -562
  31. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +566 -0
  32. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +5 -1
  33. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +594 -0
  34. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +35 -1
  35. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +160 -0
  36. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/RecordingExportSupport.swift +3 -0
  37. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-frames.swift +203 -0
  38. package/dist/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +5 -0
  39. package/dist/src/absence-observation-errors.js +1 -1
  40. package/dist/src/adapter.js +1 -1
  41. package/dist/src/agent-browser-lifecycle.js +1 -1
  42. package/dist/src/agent-browser-process-record.js +1 -1
  43. package/dist/src/agent-device-client.js +2 -2
  44. package/dist/src/alert-runtime.js +1 -1
  45. package/dist/src/android-adb.js +1 -1
  46. package/dist/src/android-input-ownership.js +1 -1
  47. package/dist/src/android.js +1 -1
  48. package/dist/src/app-launch.js +1 -1
  49. package/dist/src/app-log-runtime2.js +3 -3
  50. package/dist/src/app-resolution.js +2 -2
  51. package/dist/src/apple-runner-platform.js +1 -1
  52. package/dist/src/application-lifecycle-interaction.js +1 -1
  53. package/dist/src/artifact-paths.js +1 -0
  54. package/dist/src/back-mode.js +1 -1
  55. package/dist/src/cli-config.js +1 -1
  56. package/dist/src/cli-help.js +15 -11
  57. package/dist/src/cli.js +4 -4
  58. package/dist/src/client-types.d.ts +51 -1
  59. package/dist/src/command-tools.js +3 -3
  60. package/dist/src/config.js +1 -1
  61. package/dist/src/connection-runtime.js +1 -1
  62. package/dist/src/device-claim-inspection.js +1 -1
  63. package/dist/src/device-claim-rule.js +2 -2
  64. package/dist/src/device-input-state.d.ts +1 -1
  65. package/dist/src/device-rotation.js +1 -1
  66. package/dist/src/device-selection-resolver.js +1 -1
  67. package/dist/src/device-session.js +1 -1
  68. package/dist/src/device.js +1 -1
  69. package/dist/src/device2.js +1 -1
  70. package/dist/src/devicectl.js +1 -1
  71. package/dist/src/dispatch-resolve.js +1 -1
  72. package/dist/src/display-inventory.js +1 -1
  73. package/dist/src/doctor-facade.js +1 -1
  74. package/dist/src/durable-resource.js +1 -1
  75. package/dist/src/exec.js +1 -1
  76. package/dist/src/find.js +1 -1
  77. package/dist/src/finders.js +1 -1
  78. package/dist/src/fold-runtime.js +1 -0
  79. package/dist/src/gesture-admission.js +1 -1
  80. package/dist/src/gesture-runtime.js +1 -1
  81. package/dist/src/harmonyos.js +1 -1
  82. package/dist/src/helper.js +2 -2
  83. package/dist/src/host.js +1 -0
  84. package/dist/src/interaction-positionals.js +1 -1
  85. package/dist/src/interaction-ref-policy.js +1 -1
  86. package/dist/src/interaction.js +1 -1
  87. package/dist/src/interaction2.js +1 -1
  88. package/dist/src/interactor-operation-binding.js +1 -0
  89. package/dist/src/interactor-operation-catalog.js +1 -1
  90. package/dist/src/interactor.js +4 -5
  91. package/dist/src/interactor2.js +1 -1
  92. package/dist/src/interactors.js +1 -1
  93. package/dist/src/internal/bin.js +1 -1
  94. package/dist/src/internal/daemon.js +2 -2
  95. package/dist/src/internal/png-worker.d.ts +5 -0
  96. package/dist/src/internal/png-worker.js +1 -1
  97. package/dist/src/inventory2.js +1 -1
  98. package/dist/src/ios-snapshot-acquisition.js +1 -1
  99. package/dist/src/ios-snapshot-runtime.js +1 -1
  100. package/dist/src/lease.js +1 -1
  101. package/dist/src/linux.js +1 -1
  102. package/dist/src/local-interactor-operation-set.js +1 -1
  103. package/dist/src/location-coordinates.js +1 -1
  104. package/dist/src/macos-facade.js +1 -1
  105. package/dist/src/managed-device-scope.js +1 -1
  106. package/dist/src/mechanics.js +17 -16
  107. package/dist/src/mobile-snapshot-semantics.js +1 -1
  108. package/dist/src/mp4-duration.js +1 -0
  109. package/dist/src/multitouch-support.js +1 -1
  110. package/dist/src/owner-identity.js +1 -1
  111. package/dist/src/parameterized-recorded-fill.js +1 -1
  112. package/dist/src/perf-facade.js +2 -2
  113. package/dist/src/perf-runtime-plan.js +1 -1
  114. package/dist/src/physical-device-control.js +1 -1
  115. package/dist/src/platform-request-scope.js +1 -1
  116. package/dist/src/platform-runtime-managed-owner.js +1 -1
  117. package/dist/src/platform-runtime-open-target.js +1 -1
  118. package/dist/src/platform-runtime-operation-host.js +2 -2
  119. package/dist/src/platform-runtime-runtime-hints.js +1 -1
  120. package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -1
  121. package/dist/src/platform-runtime-unavailable.js +1 -1
  122. package/dist/src/platform-runtime.js +1 -1
  123. package/dist/src/platform-runtime2.js +1 -1
  124. package/dist/src/png-crop-bytes.js +1 -1
  125. package/dist/src/png-encode.js +1 -0
  126. package/dist/src/png-size.js +1 -0
  127. package/dist/src/png-worker-client.js +1 -1
  128. package/dist/src/provider-webdriver.js +1 -1
  129. package/dist/src/quality-warnings.js +1 -1
  130. package/dist/src/react-native.js +1 -1
  131. package/dist/src/record-runtime.js +1 -1
  132. package/dist/src/recording-export-quality.js +1 -1
  133. package/dist/src/recording-scope.js +1 -0
  134. package/dist/src/recording.js +3 -0
  135. package/dist/src/registry.js +35 -35
  136. package/dist/src/replay.js +1 -1
  137. package/dist/src/request-admission.js +1 -1
  138. package/dist/src/request-runtime-binding.js +1 -1
  139. package/dist/src/result-serialization.js +1 -1
  140. package/dist/src/runner-cache-metadata.js +2 -2
  141. package/dist/src/runner-client.js +1 -1
  142. package/dist/src/runner-contract.js +3 -0
  143. package/dist/src/runner-dev-tools-security.js +1 -0
  144. package/dist/src/runner-device-readiness.js +1 -0
  145. package/dist/src/runner-disposal.js +1 -1
  146. package/dist/src/runner-operations-facade.js +1 -1
  147. package/dist/src/runner-presentation.js +1 -1
  148. package/dist/src/runner.js +1 -1
  149. package/dist/src/runtime.js +2 -2
  150. package/dist/src/runtime10.js +2 -1
  151. package/dist/src/runtime2.js +4 -4
  152. package/dist/src/runtime4.js +5 -5
  153. package/dist/src/runtime5.js +1 -1
  154. package/dist/src/runtime6.js +1 -1
  155. package/dist/src/runtime7.js +1 -1
  156. package/dist/src/runtime8.js +1 -1
  157. package/dist/src/runtime9.js +1 -1
  158. package/dist/src/screen-recording-admission-ledger.js +1 -1
  159. package/dist/src/screen-recording-resource-recovery.js +1 -1
  160. package/dist/src/screen-recording-transport.js +1 -1
  161. package/dist/src/screenshot-density.js +1 -1
  162. package/dist/src/screenshot-image.js +1 -0
  163. package/dist/src/screenshot-overlay-draw.js +1 -0
  164. package/dist/src/screenshot-runtime.js +1 -1
  165. package/dist/src/script.js +1 -1
  166. package/dist/src/scroll-gesture.js +1 -1
  167. package/dist/src/sdk-batch.js +2 -2
  168. package/dist/src/sdk-contracts.d.ts +7 -5
  169. package/dist/src/sdk-contracts.js +2 -2
  170. package/dist/src/sdk-device.js +1 -1
  171. package/dist/src/sdk-finders.js +1 -1
  172. package/dist/src/sdk-selectors.d.ts +58 -6
  173. package/dist/src/sdk-selectors.js +1 -1
  174. package/dist/src/selection.js +0 -0
  175. package/dist/src/selector-runtime.js +1 -1
  176. package/dist/src/selectors.js +1 -1
  177. package/dist/src/semantic-index.js +1 -1
  178. package/dist/src/session-routing.js +1 -1
  179. package/dist/src/session-snapshot.js +1 -1
  180. package/dist/src/session-store.js +3 -3
  181. package/dist/src/session2.js +3 -3
  182. package/dist/src/simctl.js +1 -1
  183. package/dist/src/simulator-boot.js +1 -1
  184. package/dist/src/simulator-facade.js +1 -1
  185. package/dist/src/simulator.js +1 -1
  186. package/dist/src/snapshot-runtime-binding.js +1 -1
  187. package/dist/src/snapshot-runtime.js +1 -1
  188. package/dist/src/snapshot-state.js +1 -1
  189. package/dist/src/snapshot4.js +1 -1
  190. package/dist/src/src.js +1 -1
  191. package/dist/src/src7.js +1 -1
  192. package/dist/src/src8.js +1 -1
  193. package/dist/src/stop-sequence.js +1 -1
  194. package/dist/src/text-entry-focus.js +1 -1
  195. package/dist/src/touch-runtime.js +1 -1
  196. package/dist/src/tree.js +1 -1
  197. package/dist/src/type-text-runtime.js +1 -1
  198. package/dist/src/video.js +2 -2
  199. package/dist/src/web2.js +1 -1
  200. package/package.json +3 -1
  201. package/server.json +2 -2
  202. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.7.apk.sha256 +0 -1
  203. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.7.apk.sha256 +0 -1
  204. package/dist/src/png-transcode.js +0 -1
@@ -1,3 +1,4 @@
1
+ import AgentDeviceMacOSInput
1
2
  import AppKit
2
3
  import ApplicationServices
3
4
  import CoreGraphics
@@ -63,6 +64,10 @@ struct ReadResponse: Encodable {
63
64
  struct PressResponse: Encodable {
64
65
  let x: Double
65
66
  let y: Double
67
+ /// Hold actually posted; a short request is raised to the deliverable floor.
68
+ let holdMs: Int
69
+ let clicks: Int
70
+ let doubleClick: Bool
66
71
  let bundleId: String?
67
72
  let surface: String?
68
73
  }
@@ -122,6 +127,8 @@ struct AgentDeviceMacOSHelper {
122
127
  return try handleScreenshot(arguments: Array(arguments.dropFirst()))
123
128
  case "audio-probe":
124
129
  return try handleAudioProbe(arguments: Array(arguments.dropFirst()))
130
+ case "device-hub":
131
+ return try handleDeviceHub(arguments: Array(arguments.dropFirst()))
125
132
  default:
126
133
  throw HelperError.invalidArgs("unknown command: \(command)")
127
134
  }
@@ -378,10 +385,48 @@ struct AgentDeviceMacOSHelper {
378
385
  throw HelperError.invalidArgs("press requires --x <number> --y <number>")
379
386
  }
380
387
 
388
+ let holdMs = try validatedPressInt(
389
+ optionValue(arguments: arguments, name: "--hold-ms"),
390
+ name: "--hold-ms",
391
+ minimum: 0,
392
+ default: 0
393
+ )
394
+ let clicks = try validatedPressInt(
395
+ optionValue(arguments: arguments, name: "--clicks"),
396
+ name: "--clicks",
397
+ minimum: 1,
398
+ default: 1
399
+ )
400
+ let intervalMs = try validatedPressInt(
401
+ optionValue(arguments: arguments, name: "--interval-ms"),
402
+ name: "--interval-ms",
403
+ minimum: 0,
404
+ default: 120
405
+ )
406
+
407
+ let doubleClick = arguments.contains("--double-click")
381
408
  let bundleId = try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
382
409
  let surface = optionValue(arguments: arguments, name: "--surface")
383
- try pressAtPosition(bundleId: bundleId, surface: surface, x: x, y: y)
384
- return SuccessEnvelope(data: PressResponse(x: x, y: y, bundleId: bundleId, surface: surface))
410
+ let request = MouseClickRequest(
411
+ x: x,
412
+ y: y,
413
+ holdMs: holdMs,
414
+ clicks: clicks,
415
+ doubleClick: doubleClick,
416
+ intervalMs: intervalMs
417
+ )
418
+ try pressAtPosition(request)
419
+ return SuccessEnvelope(
420
+ data: PressResponse(
421
+ x: x,
422
+ y: y,
423
+ holdMs: mouseClickHoldMs(requestedMs: holdMs),
424
+ clicks: clicks,
425
+ doubleClick: doubleClick,
426
+ bundleId: bundleId,
427
+ surface: surface
428
+ )
429
+ )
385
430
  }
386
431
 
387
432
  static func handleScreenshot(arguments: [String]) throws -> any Encodable {
@@ -434,6 +479,21 @@ private func intOption(arguments: [String], name: String) -> Int? {
434
479
  return Int(value)
435
480
  }
436
481
 
482
+ private func validatedPressInt(
483
+ _ raw: String?,
484
+ name: String,
485
+ minimum: Int,
486
+ default fallback: Int
487
+ ) throws -> Int {
488
+ guard let raw else {
489
+ return fallback
490
+ }
491
+ guard let value = Int(raw), value >= minimum else {
492
+ throw HelperError.invalidArgs("press \(name) must be an integer of at least \(minimum)")
493
+ }
494
+ return value
495
+ }
496
+
437
497
  private func readTextAtPosition(bundleId: String?, surface: String?, x: Double, y: Double) throws -> String {
438
498
  let targetApp: NSRunningApplication?
439
499
  if surface == "frontmost-app" || (surface == nil && bundleId != nil) {
@@ -481,19 +541,12 @@ private func readTextAtPosition(bundleId: String?, surface: String?, x: Double,
481
541
  throw HelperError.commandFailed("read did not resolve text")
482
542
  }
483
543
 
484
- private func pressAtPosition(bundleId: String?, surface: String?, x: Double, y: Double) throws {
485
- _ = bundleId
486
- _ = surface
487
- let point = CGPoint(x: x, y: y)
488
- guard let move = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left),
489
- let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
490
- let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
491
- else {
544
+ private func pressAtPosition(_ request: MouseClickRequest) throws {
545
+ do {
546
+ try postMouseClick(request)
547
+ } catch MouseClickDeliveryError.eventCreationFailed {
492
548
  throw HelperError.commandFailed("press action failed", details: ["reason": "event_creation_failed"])
493
549
  }
494
- move.post(tap: .cghidEventTap)
495
- down.post(tap: .cghidEventTap)
496
- up.post(tap: .cghidEventTap)
497
550
  }
498
551
 
499
552
  private func captureSurfaceScreenshot(surface: String?, outPath: String, fullscreen: Bool) throws {
@@ -0,0 +1,116 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ public struct MouseClickRequest: Equatable, Sendable {
5
+ public let x: Double
6
+ public let y: Double
7
+ /// How long the button stays down. Zero asks for the default hold.
8
+ public let holdMs: Int
9
+ /// Independent presses, each at click state 1.
10
+ public let clicks: Int
11
+ /// Post every press as a double-click pair with a rising click state.
12
+ public let doubleClick: Bool
13
+ public let intervalMs: Int
14
+
15
+ public init(
16
+ x: Double,
17
+ y: Double,
18
+ holdMs: Int = 0,
19
+ clicks: Int = 1,
20
+ doubleClick: Bool = false,
21
+ intervalMs: Int = 120
22
+ ) {
23
+ self.x = x
24
+ self.y = y
25
+ self.holdMs = holdMs
26
+ self.clicks = clicks
27
+ self.doubleClick = doubleClick
28
+ self.intervalMs = intervalMs
29
+ }
30
+ }
31
+
32
+ public enum MouseClickDeliveryError: Error, Equatable {
33
+ case eventCreationFailed
34
+ }
35
+
36
+ /// The button the helper currently holds down, kept where a signal handler can reach it.
37
+ /// A helper killed between a mouse-down and its mouse-up would otherwise leave the system's
38
+ /// primary button stuck down for whatever the user touches next. The host stops the helper
39
+ /// with SIGTERM before SIGKILL (`runMacOsHelper` in `helper.ts`) so that this handler runs
40
+ /// on a deadline and on a cancelled request, not only on a signal sent by hand.
41
+ nonisolated(unsafe) private var heldMouseButton: (point: CGPoint, clickState: Int)?
42
+
43
+ private func releaseHeldMouseButton() {
44
+ guard let held = heldMouseButton else { return }
45
+ heldMouseButton = nil
46
+ let up = CGEvent(
47
+ mouseEventSource: nil,
48
+ mouseType: .leftMouseUp,
49
+ mouseCursorPosition: held.point,
50
+ mouseButton: .left
51
+ )
52
+ up?.setIntegerValueField(.mouseEventClickState, value: Int64(held.clickState))
53
+ up?.post(tap: .cghidEventTap)
54
+ }
55
+
56
+ private func installMouseReleaseOnTermination() {
57
+ for terminationSignal in [SIGTERM, SIGINT, SIGHUP] {
58
+ signal(terminationSignal) { received in
59
+ releaseHeldMouseButton()
60
+ _exit(128 + received)
61
+ }
62
+ }
63
+ }
64
+
65
+ /// Posts a click the way a trackpad would: one motion to the point, then each press held
66
+ /// long enough for the app to accept the release. A termination signal that lands inside a
67
+ /// hold releases the button before the process exits.
68
+ public func postMouseClick(_ request: MouseClickRequest) throws {
69
+ installMouseReleaseOnTermination()
70
+ let point = CGPoint(x: request.x, y: request.y)
71
+ let hold = mouseClickHoldMs(requestedMs: request.holdMs)
72
+ let presses = mouseClickPresses(
73
+ clicks: request.clicks,
74
+ doubleClick: request.doubleClick,
75
+ intervalMs: request.intervalMs
76
+ )
77
+
78
+ guard let move = CGEvent(
79
+ mouseEventSource: nil,
80
+ mouseType: .mouseMoved,
81
+ mouseCursorPosition: point,
82
+ mouseButton: .left
83
+ ) else {
84
+ throw MouseClickDeliveryError.eventCreationFailed
85
+ }
86
+ move.post(tap: .cghidEventTap)
87
+
88
+ for press in presses {
89
+ if press.delayBeforeMs > 0 {
90
+ usleep(UInt32(press.delayBeforeMs) * 1000)
91
+ }
92
+ guard let down = CGEvent(
93
+ mouseEventSource: nil,
94
+ mouseType: .leftMouseDown,
95
+ mouseCursorPosition: point,
96
+ mouseButton: .left
97
+ ), let up = CGEvent(
98
+ mouseEventSource: nil,
99
+ mouseType: .leftMouseUp,
100
+ mouseCursorPosition: point,
101
+ mouseButton: .left
102
+ ) else {
103
+ throw MouseClickDeliveryError.eventCreationFailed
104
+ }
105
+ down.setIntegerValueField(.mouseEventClickState, value: Int64(press.clickState))
106
+ up.setIntegerValueField(.mouseEventClickState, value: Int64(press.clickState))
107
+ heldMouseButton = (point, press.clickState)
108
+ down.post(tap: .cghidEventTap)
109
+ usleep(UInt32(hold) * 1000)
110
+ // The record clears only after the up is posted: a signal that lands between the two
111
+ // would otherwise find nothing to release and exit with the button still down. A signal
112
+ // that lands after the post releases a button already up, which is harmless.
113
+ up.post(tap: .cghidEventTap)
114
+ heldMouseButton = nil
115
+ }
116
+ }
@@ -0,0 +1,61 @@
1
+ import Foundation
2
+
3
+ /// AppKit and SwiftUI keep a mouse-down under track long enough to tell a click from a
4
+ /// drag, and a mouse-up posted in the same event tick as its mouse-down is never
5
+ /// delivered to the app. Measured on an `NSButton`: a 0 ms hold delivered 0 of 15
6
+ /// mouse-ups and activated 0 actions, 20 ms activated 14 of 15, and 40 ms and above
7
+ /// activated 15 of 15. Holds shorter than this are raised to it.
8
+ public let minimumMouseClickHoldMs = 40
9
+
10
+ /// Hold used when the caller does not name one. Comfortably above
11
+ /// `minimumMouseClickHoldMs` so a press does not sit on the measured cliff.
12
+ public let defaultMouseClickHoldMs = 60
13
+
14
+ /// The gap between the two presses of one double-click. Well inside the system
15
+ /// double-click interval (500 ms by default), and independent of the caller's repeat
16
+ /// interval, which separates whole presses rather than the halves of one.
17
+ public let mouseClickPairGapMs = 80
18
+
19
+ public func mouseClickHoldMs(requestedMs: Int) -> Int {
20
+ if requestedMs <= 0 {
21
+ return defaultMouseClickHoldMs
22
+ }
23
+ return max(requestedMs, minimumMouseClickHoldMs)
24
+ }
25
+
26
+ /// One press of the button: the click state it is posted with, and how long after the
27
+ /// previous release it starts.
28
+ public struct MouseClickPress: Equatable, Sendable {
29
+ /// `1` for an independent click; `2` for the second half of a double-click.
30
+ public let clickState: Int
31
+ public let delayBeforeMs: Int
32
+
33
+ public init(clickState: Int, delayBeforeMs: Int) {
34
+ self.clickState = clickState
35
+ self.delayBeforeMs = delayBeforeMs
36
+ }
37
+ }
38
+
39
+ /// The presses one request becomes. `clicks` is always a count of independent presses at
40
+ /// click state 1, which is what every other platform means by `--count`; only
41
+ /// `doubleClick` raises the state, and it does so per press, so `doubleClick` with three
42
+ /// clicks is three double-clicks rather than one triple-click.
43
+ public func mouseClickPresses(clicks: Int, doubleClick: Bool, intervalMs: Int) -> [MouseClickPress] {
44
+ let gap = max(intervalMs, 0)
45
+ var presses: [MouseClickPress] = []
46
+ for index in 0..<max(clicks, 1) {
47
+ presses.append(MouseClickPress(clickState: 1, delayBeforeMs: index == 0 ? 0 : gap))
48
+ if doubleClick {
49
+ presses.append(MouseClickPress(clickState: 2, delayBeforeMs: mouseClickPairGapMs))
50
+ }
51
+ }
52
+ return presses
53
+ }
54
+
55
+ /// How long the schedule keeps the helper busy: every hold plus every gap. The caller's
56
+ /// process timeout must cover this, or the helper is killed mid-schedule.
57
+ public func mouseClickScheduleMs(holdMs: Int, clicks: Int, doubleClick: Bool, intervalMs: Int) -> Int {
58
+ let hold = mouseClickHoldMs(requestedMs: holdMs)
59
+ return mouseClickPresses(clicks: clicks, doubleClick: doubleClick, intervalMs: intervalMs)
60
+ .reduce(0) { $0 + $1.delayBeforeMs + hold }
61
+ }
@@ -0,0 +1,30 @@
1
+ import XCTest
2
+
3
+ @testable import AgentDeviceMacOSDeviceHub
4
+
5
+ final class DeviceHubPoseTests: XCTestCase {
6
+ func testEveryPoseNamesTheActionBarControlDeviceHubDescribes() {
7
+ XCTAssertEqual(DeviceHubPose(argument: "closed")?.controlDescription, "Closed")
8
+ XCTAssertEqual(DeviceHubPose(argument: "book")?.controlDescription, "Book")
9
+ XCTAssertEqual(DeviceHubPose(argument: "OPEN")?.controlDescription, "Open")
10
+ XCTAssertNil(DeviceHubPose(argument: "half-open"), "the helper takes Device Hub's names, not the CLI's")
11
+ XCTAssertEqual(Set(DeviceHubPose.allCases.map(\.controlDescription)), ["Closed", "Book", "Open"])
12
+ }
13
+
14
+ // Device Hub titles the window `<name> – iOS 27.1`; a simulator whose name merely starts
15
+ // with another's must not claim that window.
16
+ func testWindowTitleMatchesTheDeviceNameAsAPrefixWord() {
17
+ XCTAssertTrue(deviceHubWindowShows(deviceName: "iPhone Duo", title: "iPhone Duo – iOS 27.1"))
18
+ XCTAssertTrue(deviceHubWindowShows(deviceName: "iPhone Duo", title: "iPhone Duo"))
19
+ XCTAssertFalse(deviceHubWindowShows(deviceName: "iPhone Duo", title: "iPhone Duo Lab – iOS 27.1"))
20
+ XCTAssertFalse(deviceHubWindowShows(deviceName: "iPhone Duo", title: "bench-golden-v1 – iOS 27.0"))
21
+ XCTAssertFalse(deviceHubWindowShows(deviceName: "iPhone Duo", title: nil))
22
+ }
23
+
24
+ func testSidebarRowIsKeyedByUdid() {
25
+ XCTAssertEqual(
26
+ deviceHubDeviceRowIdentifier(udid: "4F879835-4AB3-4046-B033-5AB769209DD4"),
27
+ "TableRow.Device.4F879835-4AB3-4046-B033-5AB769209DD4"
28
+ )
29
+ }
30
+ }
@@ -0,0 +1,65 @@
1
+ import ApplicationServices
2
+ import XCTest
3
+ @testable import AgentDeviceMacOSDeviceHub
4
+
5
+ final class DeviceHubWindowInventoryTests: XCTestCase {
6
+ func testLaterWindowKeepsItsProcessAndDoesNotPreventEmptyProcessReopen() {
7
+ var inventory = DeviceHubWindowInventory()
8
+ let window = AXUIElementCreateApplication(20)
9
+ inventory.record(processID: 10, read: .available([]))
10
+ inventory.record(processID: 20, read: .available([window]))
11
+ XCTAssertEqual(inventory.candidates.map(\.processID), [20])
12
+ XCTAssertTrue(CFEqual(inventory.candidates[0].window, window))
13
+ inventory.record(candidate: 0, outcome: .sidebarUnavailable)
14
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [10])
15
+ XCTAssertEqual(inventory.failureReason(), "device-hub-identity-unconfirmed")
16
+ XCTAssertEqual(inventory.candidateOutcomes, ["20:0:sidebar-unavailable"])
17
+ }
18
+
19
+ func testFailedReadsAreNotReopenedOrMisreportedAsMissingWindows() {
20
+ var inventory = DeviceHubWindowInventory()
21
+ inventory.record(processID: 10, read: .failed(-25204))
22
+ inventory.record(processID: 20, read: .available([]))
23
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [20])
24
+ XCTAssertEqual(inventory.failures, [10: -25204])
25
+ XCTAssertEqual(inventory.failureReason(), "device-hub-window-read-failed")
26
+ }
27
+
28
+ func testSuccessfulEmptyReadIsMissingAndReopenIsAttemptedOnlyOnce() {
29
+ var inventory = DeviceHubWindowInventory()
30
+ inventory.record(processID: 10, read: .available([]))
31
+ XCTAssertEqual(inventory.failureReason(), "device-hub-window-missing")
32
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [10])
33
+ XCTAssertEqual(inventory.processesToReopen(attempted: [10]), [])
34
+ }
35
+
36
+ func testSharedBudgetExhaustionStopsReopenWithoutClaimingMissingDevice() {
37
+ var inventory = DeviceHubWindowInventory()
38
+ inventory.record(processID: 10, read: .available([]))
39
+ inventory.expireBudget()
40
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [])
41
+ XCTAssertEqual(inventory.failureReason(), "device-hub-discovery-timeout")
42
+ }
43
+
44
+ func testCandidateOutcomesRetainUncertaintyAndOwningProcess() {
45
+ var inventory = DeviceHubWindowInventory()
46
+ inventory.record(processID: 10, read: .available([AXUIElementCreateApplication(10)]))
47
+ inventory.record(processID: 20, read: .available([AXUIElementCreateApplication(20)]))
48
+ inventory.record(candidate: 0, outcome: .timedOut)
49
+ inventory.record(candidate: 1, outcome: .unconfirmed)
50
+ XCTAssertEqual(inventory.candidateOutcomes, ["10:0:timed-out", "20:1:row-unconfirmed"])
51
+ XCTAssertEqual(inventory.failureReason(), "device-hub-identity-unconfirmed")
52
+ }
53
+ func testUnrelatedReopenFailureDoesNotMaskCandidateUncertainty() {
54
+ var inventory = DeviceHubWindowInventory()
55
+ inventory.record(processID: 10, read: .available([]))
56
+ let failures: [Int32: String] = [10: "automation-permission"]
57
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "automation-permission")
58
+ inventory.record(processID: 20, read: .available([AXUIElementCreateApplication(20)]))
59
+ inventory.record(candidate: 0, outcome: .timedOut)
60
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "device-hub-identity-unconfirmed")
61
+ inventory.expireBudget()
62
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "device-hub-discovery-timeout")
63
+ }
64
+
65
+ }
@@ -0,0 +1,58 @@
1
+ import XCTest
2
+
3
+ @testable import AgentDeviceMacOSInput
4
+
5
+ final class MouseClickScheduleTests: XCTestCase {
6
+ // The original schedule posted move, down and up back to back. AppKit and SwiftUI
7
+ // never delivered the release, so every surface press opened a tracking session that
8
+ // no control ever completed: 0 of 15 measured clicks activated their control.
9
+ func testHoldShorterThanTheDeliverableFloorIsRaised() {
10
+ XCTAssertEqual(mouseClickHoldMs(requestedMs: 0), defaultMouseClickHoldMs)
11
+ XCTAssertEqual(mouseClickHoldMs(requestedMs: 1), minimumMouseClickHoldMs)
12
+ XCTAssertEqual(mouseClickHoldMs(requestedMs: 39), minimumMouseClickHoldMs)
13
+ XCTAssertGreaterThan(defaultMouseClickHoldMs, minimumMouseClickHoldMs)
14
+ }
15
+
16
+ func testNamedLongPressHoldIsKept() {
17
+ XCTAssertEqual(mouseClickHoldMs(requestedMs: 800), 800)
18
+ }
19
+
20
+ // `--count N` means N independent presses on every platform; a rising click state would
21
+ // turn `--count 2` into a double-click and `--count 3` into a triple-click.
22
+ func testRepeatClicksAreIndependentPressesAtClickStateOne() {
23
+ let presses = mouseClickPresses(clicks: 3, doubleClick: false, intervalMs: 150)
24
+ XCTAssertEqual(presses.map(\.clickState), [1, 1, 1])
25
+ XCTAssertEqual(presses.map(\.delayBeforeMs), [0, 150, 150])
26
+ }
27
+
28
+ // Only `--double-tap` raises the click state, and it does so per press, so it composes
29
+ // with `--count` into that many double-clicks rather than one triple-click.
30
+ func testDoubleClickRaisesTheStateInsideEachPress() {
31
+ XCTAssertEqual(
32
+ mouseClickPresses(clicks: 1, doubleClick: true, intervalMs: 120).map(\.clickState),
33
+ [1, 2]
34
+ )
35
+ let three = mouseClickPresses(clicks: 3, doubleClick: true, intervalMs: 500)
36
+ XCTAssertEqual(three.map(\.clickState), [1, 2, 1, 2, 1, 2])
37
+ XCTAssertEqual(
38
+ three.map(\.delayBeforeMs),
39
+ [0, mouseClickPairGapMs, 500, mouseClickPairGapMs, 500, mouseClickPairGapMs]
40
+ )
41
+ XCTAssertLessThan(mouseClickPairGapMs, 500, "a pair must land inside the system double-click interval")
42
+ }
43
+
44
+ func testZeroClicksStillPostsOnePress() {
45
+ XCTAssertEqual(mouseClickPresses(clicks: 0, doubleClick: false, intervalMs: 0).count, 1)
46
+ }
47
+
48
+ // The process timeout the caller sets is derived from this: a schedule the timeout does
49
+ // not cover is killed mid-hold.
50
+ func testScheduleDurationSumsEveryHoldAndGap() {
51
+ XCTAssertEqual(mouseClickScheduleMs(holdMs: 0, clicks: 1, doubleClick: false, intervalMs: 120), defaultMouseClickHoldMs)
52
+ XCTAssertEqual(mouseClickScheduleMs(holdMs: 10_000, clicks: 4, doubleClick: false, intervalMs: 120), 4 * 10_000 + 3 * 120)
53
+ XCTAssertEqual(
54
+ mouseClickScheduleMs(holdMs: 60, clicks: 2, doubleClick: true, intervalMs: 100),
55
+ 4 * 60 + 2 * mouseClickPairGapMs + 100
56
+ )
57
+ }
58
+ }
@@ -2,3 +2,4 @@
2
2
  #import "RunnerAXSnapshotBridge.h"
3
3
  #import "RunnerSynthesizedGesture.h"
4
4
  #import "RunnerSynthesizedTextEntry.h"
5
+ #import "RunnerXCTestEventBridge.h"
@@ -0,0 +1,175 @@
1
+ import XCTest
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
5
+
6
+
7
+
8
+
9
+ struct CapturedAppScreen {
10
+ let image: RunnerImage
11
+ let displayID: UInt
12
+ let pixelWidth: Int
13
+ let pixelHeight: Int
14
+ let pixelsPerPoint: Double
15
+ }
16
+
17
+
18
+
19
+
20
+ enum RunnerAppScreenCaptureFailure: String, Error {
21
+ case unresolvedWindow = "APP_SCREEN_WINDOW_UNRESOLVED"
22
+ case unresolvedScreen = "APP_SCREEN_UNRESOLVED"
23
+ case unrenderableImage = "APP_SCREEN_CAPTURE_UNRENDERABLE"
24
+
25
+ init(_ failure: RunnerApplicationScreenFailure) {
26
+ switch failure {
27
+ case .unresolvedWindow:
28
+ self = .unresolvedWindow
29
+ default:
30
+ self = .unresolvedScreen
31
+ }
32
+ }
33
+
34
+ var message: String {
35
+ switch self {
36
+ case .unresolvedWindow:
37
+ return "Neither the target app nor the system surface resolved a window, so no display could be named."
38
+ case .unresolvedScreen:
39
+ return "The resolved app window did not report the display it is shown on."
40
+ case .unrenderableImage:
41
+ return "The resolved display handed back an image that could not be drawn upright."
42
+ }
43
+ }
44
+
45
+ var hint: String {
46
+ switch self {
47
+ case .unresolvedWindow:
48
+ return "The capture follows the display that owns a window, so this is a runtime that is answering no window for any process; runner.log names the window frame and the query answer it refused."
49
+ case .unresolvedScreen:
50
+ return "Retry the capture. If it persists, the XCTest runtime is not reporting the window's display; runner.log names the window frame it resolved."
51
+ case .unrenderableImage:
52
+ return "Retry the capture. The display was resolved, so this is the capture itself failing, not a panel question."
53
+ }
54
+ }
55
+ }
56
+
57
+ #if canImport(UIKit) && os(iOS)
58
+ extension RunnerTests {
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+ func captureResolvedAppScreen(
71
+ app: XCUIApplication
72
+ ) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
73
+ var outcome: Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> = .failure(
74
+ .unresolvedScreen
75
+ )
76
+ let capture = {
77
+ outcome = Self.resolveCapturedAppScreen(app: app)
78
+ }
79
+ if Thread.isMainThread {
80
+ capture()
81
+ } else {
82
+ DispatchQueue.main.sync(execute: capture)
83
+ }
84
+ return outcome
85
+ }
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+ func captureObservedScreen(app: XCUIApplication) -> Result<
98
+ CapturedAppScreen, RunnerAppScreenCaptureFailure
99
+ > {
100
+ selectObservedScreenCapture(
101
+ resolving: { captureResolvedAppScreen(app: app) },
102
+ fallingBack: { captureResolvedAppScreen(app: springboard) }
103
+ )
104
+ }
105
+
106
+
107
+
108
+ func selectObservedScreenCapture(
109
+ resolving: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
110
+ fallingBack: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
111
+ ) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
112
+ let outcome = resolving()
113
+ if case .failure(.unresolvedWindow) = outcome {
114
+ return fallingBack()
115
+ }
116
+ return outcome
117
+ }
118
+
119
+ private static func resolveCapturedAppScreen(
120
+ app: XCUIApplication
121
+ ) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
122
+ var screen: AnyObject?
123
+ var displayID: UInt = 0
124
+ var resolution: RunnerApplicationScreenFailure = .unresolvedScreen
125
+ guard RunnerResolveApplicationScreen(app, &screen, &displayID, &resolution),
126
+ let captureScreen = screen as? XCUIScreen
127
+ else {
128
+ return .failure(RunnerAppScreenCaptureFailure(resolution))
129
+ }
130
+ let image = captureScreen.screenshot().image
131
+ guard let upright = runnerUprightCaptureImage(image) else {
132
+ return .failure(.unrenderableImage)
133
+ }
134
+
135
+
136
+
137
+ guard upright.scale.isFinite, upright.scale > 0 else {
138
+ return .failure(.unrenderableImage)
139
+ }
140
+ guard let cgImage = runnerCGImage(from: upright) else {
141
+ return .failure(.unrenderableImage)
142
+ }
143
+ return .success(
144
+ CapturedAppScreen(
145
+ image: upright,
146
+ displayID: displayID,
147
+ pixelWidth: cgImage.width,
148
+ pixelHeight: cgImage.height,
149
+ pixelsPerPoint: Double(upright.scale)
150
+ )
151
+ )
152
+ }
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+ private static func runnerUprightCaptureImage(_ image: UIImage) -> UIImage? {
161
+ if image.imageOrientation == .up {
162
+ return image
163
+ }
164
+ guard image.size.width > 0, image.size.height > 0, image.scale.isFinite, image.scale > 0 else {
165
+ return nil
166
+ }
167
+ let format = UIGraphicsImageRendererFormat()
168
+ format.scale = image.scale
169
+ let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
170
+ return renderer.image { _ in
171
+ image.draw(in: CGRect(origin: .zero, size: image.size))
172
+ }
173
+ }
174
+ }
175
+ #endif
@@ -8,7 +8,11 @@ FOUNDATION_EXPORT double RunnerControlledScrollProgress(double t);
8
8
 
9
9
  @interface RunnerSynthesizedGesture : NSObject
10
10
 
11
+ // `resolvedWindow` is the app window the caller already resolved for the gesture's reference
12
+ // frame. The record's display ID is read from that same window so geometry and routing can never
13
+ // name different windows; pass nil to fall back to the application's first window.
11
14
  + (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
15
+ resolvedWindow:(id _Nullable)resolvedWindow
12
16
  x:(double)x
13
17
  y:(double)y
14
18
  x2:(double)x2
@@ -16,26 +20,30 @@ FOUNDATION_EXPORT double RunnerControlledScrollProgress(double t);
16
20
  durationMs:(double)durationMs;
17
21
 
18
22
  + (NSString * _Nullable)synthesizeContinuousDragWithApplication:(id)application
23
+ resolvedWindow:(id _Nullable)resolvedWindow
19
24
  x:(double)x
20
25
  y:(double)y
21
26
  x2:(double)x2
22
- y2:(double)y2
27
+ y2:(double)y2
23
28
  durationMs:(double)durationMs;
24
29
 
25
30
  + (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)application
31
+ resolvedWindow:(id _Nullable)resolvedWindow
26
32
  x:(double)x
27
33
  y:(double)y
28
34
  x2:(double)x2
29
- y2:(double)y2
35
+ y2:(double)y2
30
36
  durationMs:(double)durationMs;
31
37
 
32
38
  + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
39
+ resolvedWindow:(id _Nullable)resolvedWindow
33
40
  x:(double)x
34
41
  y:(double)y;
35
42
 
36
43
  // Each pointer is an ordered array of { x, y, offsetMs } samples. The first sample
37
44
  // starts contact; subsequent samples move it; all pointers lift at their final offset.
38
45
  + (NSString * _Nullable)synthesizeGestureWithApplication:(id)application
46
+ resolvedWindow:(id _Nullable)resolvedWindow
39
47
  pointerSamples:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)pointerSamples;
40
48
 
41
49
  // UIInterfaceOrientation of the app (1 portrait, 2 upsideDown, 3 landscapeRight,