agent-device 0.21.8 → 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 (171) hide show
  1. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.8.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.8.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.8.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.8.manifest.json → agent-device-android-snapshot-helper-0.21.9.manifest.json} +6 -6
  7. package/apple/macos-helper/Sources/AgentDeviceMacOSDeviceHub/DeviceHubWindowInventory.swift +65 -0
  8. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubPose.swift +45 -140
  9. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubSidebar.swift +57 -0
  10. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubWindowDiscovery.swift +108 -0
  11. package/apple/macos-helper/Tests/AgentDeviceMacOSDeviceHubTests/DeviceHubWindowInventoryTests.swift +65 -0
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift +175 -0
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +10 -2
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +42 -15
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +8 -0
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +45 -22
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +39 -734
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +20 -4
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +63 -1
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +13 -0
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +25 -10
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +5 -562
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +566 -0
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +5 -1
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +594 -0
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +35 -1
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +160 -0
  29. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/RecordingExportSupport.swift +3 -0
  30. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-frames.swift +203 -0
  31. package/dist/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +5 -0
  32. package/dist/src/absence-observation-errors.js +1 -1
  33. package/dist/src/adapter.js +1 -1
  34. package/dist/src/agent-browser-lifecycle.js +1 -1
  35. package/dist/src/agent-browser-process-record.js +1 -1
  36. package/dist/src/agent-device-client.js +1 -1
  37. package/dist/src/alert-runtime.js +1 -1
  38. package/dist/src/android-adb.js +1 -1
  39. package/dist/src/android-input-ownership.js +1 -1
  40. package/dist/src/android.js +1 -1
  41. package/dist/src/app-launch.js +1 -1
  42. package/dist/src/app-log-runtime2.js +3 -3
  43. package/dist/src/app-resolution.js +2 -2
  44. package/dist/src/apple-runner-platform.js +1 -1
  45. package/dist/src/application-lifecycle-interaction.js +1 -1
  46. package/dist/src/artifact-paths.js +1 -0
  47. package/dist/src/back-mode.js +1 -1
  48. package/dist/src/cli-config.js +1 -1
  49. package/dist/src/cli-help.js +8 -7
  50. package/dist/src/cli.js +4 -4
  51. package/dist/src/client-types.d.ts +23 -5
  52. package/dist/src/command-tools.js +3 -3
  53. package/dist/src/connection-runtime.js +1 -1
  54. package/dist/src/device-claim-inspection.js +1 -1
  55. package/dist/src/device-claim-rule.js +2 -2
  56. package/dist/src/device-input-state.d.ts +1 -1
  57. package/dist/src/device-selection-resolver.js +1 -1
  58. package/dist/src/device.js +1 -1
  59. package/dist/src/device2.js +1 -1
  60. package/dist/src/dispatch-resolve.js +1 -1
  61. package/dist/src/doctor-facade.js +1 -1
  62. package/dist/src/durable-resource.js +1 -1
  63. package/dist/src/find.js +1 -1
  64. package/dist/src/finders.js +1 -1
  65. package/dist/src/fold-runtime.js +1 -0
  66. package/dist/src/gesture-admission.js +1 -1
  67. package/dist/src/interaction-positionals.js +1 -1
  68. package/dist/src/interaction-ref-policy.js +1 -1
  69. package/dist/src/interaction.js +1 -1
  70. package/dist/src/interaction2.js +1 -1
  71. package/dist/src/interactor-operation-catalog.js +1 -1
  72. package/dist/src/interactor.js +4 -4
  73. package/dist/src/interactors.js +1 -1
  74. package/dist/src/internal/bin.js +1 -1
  75. package/dist/src/internal/daemon.js +2 -2
  76. package/dist/src/inventory2.js +1 -1
  77. package/dist/src/ios-snapshot-acquisition.js +1 -1
  78. package/dist/src/ios-snapshot-runtime.js +1 -1
  79. package/dist/src/lease.js +1 -1
  80. package/dist/src/local-interactor-operation-set.js +1 -1
  81. package/dist/src/location-coordinates.js +1 -1
  82. package/dist/src/managed-device-scope.js +1 -1
  83. package/dist/src/mechanics.js +17 -17
  84. package/dist/src/mobile-snapshot-semantics.js +1 -1
  85. package/dist/src/mp4-duration.js +1 -0
  86. package/dist/src/multitouch-support.js +1 -1
  87. package/dist/src/owner-identity.js +1 -1
  88. package/dist/src/parameterized-recorded-fill.js +1 -1
  89. package/dist/src/perf-facade.js +2 -2
  90. package/dist/src/perf-runtime-plan.js +1 -1
  91. package/dist/src/platform-request-scope.js +1 -1
  92. package/dist/src/platform-runtime-managed-owner.js +1 -1
  93. package/dist/src/platform-runtime-open-target.js +1 -1
  94. package/dist/src/platform-runtime-operation-host.js +2 -2
  95. package/dist/src/platform-runtime-runtime-hints.js +1 -1
  96. package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -1
  97. package/dist/src/platform-runtime-unavailable.js +1 -1
  98. package/dist/src/platform-runtime.js +1 -1
  99. package/dist/src/platform-runtime2.js +1 -1
  100. package/dist/src/png-crop-bytes.js +1 -1
  101. package/dist/src/png-encode.js +1 -0
  102. package/dist/src/png-size.js +1 -0
  103. package/dist/src/provider-webdriver.js +1 -1
  104. package/dist/src/quality-warnings.js +1 -1
  105. package/dist/src/react-native.js +1 -1
  106. package/dist/src/record-runtime.js +1 -1
  107. package/dist/src/recording-export-quality.js +1 -1
  108. package/dist/src/recording-scope.js +1 -0
  109. package/dist/src/recording.js +3 -0
  110. package/dist/src/registry.js +35 -35
  111. package/dist/src/replay.js +2 -2
  112. package/dist/src/request-admission.js +1 -1
  113. package/dist/src/request-runtime-binding.js +1 -1
  114. package/dist/src/result-serialization.js +1 -1
  115. package/dist/src/runner-cache-metadata.js +2 -2
  116. package/dist/src/runner-client.js +1 -1
  117. package/dist/src/runner-contract.js +1 -1
  118. package/dist/src/runner-dev-tools-security.js +1 -1
  119. package/dist/src/runner-device-readiness.js +1 -1
  120. package/dist/src/runner-disposal.js +1 -1
  121. package/dist/src/runner-operations-facade.js +1 -1
  122. package/dist/src/runner-presentation.js +1 -1
  123. package/dist/src/runner.js +1 -1
  124. package/dist/src/runtime.js +2 -2
  125. package/dist/src/runtime10.js +2 -1
  126. package/dist/src/runtime2.js +4 -4
  127. package/dist/src/runtime4.js +5 -5
  128. package/dist/src/runtime5.js +1 -1
  129. package/dist/src/runtime6.js +1 -1
  130. package/dist/src/runtime7.js +1 -1
  131. package/dist/src/runtime8.js +1 -1
  132. package/dist/src/runtime9.js +1 -1
  133. package/dist/src/screen-recording-admission-ledger.js +1 -1
  134. package/dist/src/screen-recording-resource-recovery.js +1 -1
  135. package/dist/src/screen-recording-transport.js +1 -1
  136. package/dist/src/screenshot-density.js +1 -1
  137. package/dist/src/screenshot-overlay-draw.js +1 -0
  138. package/dist/src/screenshot-runtime.js +1 -1
  139. package/dist/src/script.js +1 -1
  140. package/dist/src/scroll-gesture.js +1 -1
  141. package/dist/src/sdk-batch.js +2 -2
  142. package/dist/src/sdk-contracts.d.ts +7 -5
  143. package/dist/src/sdk-device.js +1 -1
  144. package/dist/src/sdk-finders.js +1 -1
  145. package/dist/src/sdk-selectors.d.ts +44 -1
  146. package/dist/src/sdk-selectors.js +1 -1
  147. package/dist/src/selection.js +0 -0
  148. package/dist/src/selector-runtime.js +1 -1
  149. package/dist/src/selectors.js +1 -1
  150. package/dist/src/semantic-index.js +1 -1
  151. package/dist/src/session-routing.js +1 -1
  152. package/dist/src/session-snapshot.js +1 -1
  153. package/dist/src/session-store.js +3 -3
  154. package/dist/src/session2.js +3 -3
  155. package/dist/src/simctl.js +1 -1
  156. package/dist/src/simulator-boot.js +1 -1
  157. package/dist/src/snapshot-runtime-binding.js +1 -1
  158. package/dist/src/snapshot-runtime.js +1 -1
  159. package/dist/src/snapshot-state.js +1 -1
  160. package/dist/src/snapshot4.js +1 -1
  161. package/dist/src/src.js +1 -1
  162. package/dist/src/src7.js +1 -1
  163. package/dist/src/stop-sequence.js +1 -1
  164. package/dist/src/text-entry-focus.js +1 -1
  165. package/dist/src/tree.js +1 -1
  166. package/dist/src/type-text-runtime.js +1 -1
  167. package/dist/src/video.js +2 -2
  168. package/package.json +1 -1
  169. package/server.json +2 -2
  170. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.8.apk.sha256 +0 -1
  171. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.8.apk.sha256 +0 -1
@@ -0,0 +1,594 @@
1
+ import XCTest
2
+ import AgentDeviceSnapshotPresentation
3
+
4
+ extension RunnerTests {
5
+ func synthesizedDragAt(
6
+ app: XCUIApplication,
7
+ x: Double,
8
+ y: Double,
9
+ x2: Double,
10
+ y2: Double,
11
+ durationMs: Double,
12
+ profile: SynthesizedDragProfile = .continuous,
13
+ context: SynthesizedCoordinateContext? = nil
14
+ ) -> RunnerInteractionOutcome {
15
+ #if os(iOS)
16
+ guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
17
+ return .unsupported(
18
+ message: "synthesized coordinate drag requires finite coordinates",
19
+ hint: "Retry with finite x, y, x2, and y2 values."
20
+ )
21
+ }
22
+ let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
23
+ guard let context = context ?? synthesizedCoordinateContext(
24
+ app: app,
25
+ policy: synthesizedGesturePolicy(.synthesizedDrag)
26
+ ) else {
27
+ return .unsupported(
28
+ message: "synthesized coordinate drag could not resolve an app window with a finite screen frame",
29
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
30
+ )
31
+ }
32
+ let frame = context.referenceFrame
33
+ let start = CoordinateSpaceRotation.native(
34
+ point: CGPoint(x: x, y: y),
35
+ in: frame,
36
+ interfaceOrientation: orientation
37
+ )
38
+ let end = CoordinateSpaceRotation.native(
39
+ point: CGPoint(x: x2, y: y2),
40
+ in: frame,
41
+ interfaceOrientation: orientation
42
+ )
43
+ logSynthesizedDispatch(
44
+ kind: "drag",
45
+ start: start,
46
+ end: end,
47
+ referenceFrame: frame,
48
+ orientation: orientation
49
+ )
50
+ let message = switch profile {
51
+ case .continuous:
52
+ RunnerSynthesizedGesture.synthesizeContinuousDrag(
53
+ withApplication: app,
54
+ resolvedWindow: context.resolvedWindow,
55
+ x: Double(start.x),
56
+ y: Double(start.y),
57
+ x2: Double(end.x),
58
+ y2: Double(end.y),
59
+ durationMs: durationMs
60
+ )
61
+ case .controlledScroll:
62
+ RunnerSynthesizedGesture.synthesizeControlledScroll(
63
+ withApplication: app,
64
+ resolvedWindow: context.resolvedWindow,
65
+ x: Double(start.x),
66
+ y: Double(start.y),
67
+ x2: Double(end.x),
68
+ y2: Double(end.y),
69
+ durationMs: durationMs
70
+ )
71
+ case .fastSwipe:
72
+ RunnerSynthesizedGesture.synthesizeSwipe(
73
+ withApplication: app,
74
+ resolvedWindow: context.resolvedWindow,
75
+ x: Double(start.x),
76
+ y: Double(start.y),
77
+ x2: Double(end.x),
78
+ y2: Double(end.y),
79
+ durationMs: durationMs
80
+ )
81
+ }
82
+ if let message {
83
+ return .unsupported(
84
+ message: message,
85
+ hint: "Private XCTest event synthesis is required for AX-free coordinate drag on iOS; update Xcode if this persists."
86
+ )
87
+ }
88
+ return .performed
89
+ #elseif os(tvOS)
90
+ return .unsupported(
91
+ message: "coordinate drag is not supported on tvOS",
92
+ hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
93
+ )
94
+ #else
95
+ return .unsupported(
96
+ message: "coordinate drag is not supported on macOS",
97
+ hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
98
+ )
99
+ #endif
100
+ }
101
+
102
+ func synthesizedTapAt(
103
+ app: XCUIApplication,
104
+ x: Double,
105
+ y: Double,
106
+ context: SynthesizedCoordinateContext? = nil
107
+ ) -> RunnerInteractionOutcome {
108
+ #if os(iOS)
109
+ guard x.isFinite, y.isFinite else {
110
+ return .unsupported(
111
+ message: "synthesized coordinate tap requires finite coordinates",
112
+ hint: "Retry with finite x and y values."
113
+ )
114
+ }
115
+ let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
116
+ guard let context = context ?? synthesizedCoordinateContext(
117
+ app: app,
118
+ policy: synthesizedGesturePolicy(.coordinateTap)
119
+ ) else {
120
+ return .unsupported(
121
+ message: "synthesized coordinate tap could not resolve an app window with a finite screen frame",
122
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
123
+ )
124
+ }
125
+ let point = CoordinateSpaceRotation.native(
126
+ point: CGPoint(x: x, y: y),
127
+ in: context.referenceFrame,
128
+ interfaceOrientation: orientation
129
+ )
130
+ logSynthesizedDispatch(
131
+ kind: "tap",
132
+ start: point,
133
+ end: nil,
134
+ referenceFrame: context.referenceFrame,
135
+ orientation: orientation
136
+ )
137
+ if let message = RunnerSynthesizedGesture.synthesizeTap(
138
+ withApplication: app,
139
+ resolvedWindow: context.resolvedWindow,
140
+ x: Double(point.x),
141
+ y: Double(point.y)
142
+ ) {
143
+ return .unsupported(
144
+ message: message,
145
+ hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
146
+ )
147
+ }
148
+ return .performed
149
+ #elseif os(tvOS)
150
+ return .unsupported(
151
+ message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
152
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
153
+ )
154
+ #else
155
+ return .unsupported(
156
+ message: "synthesized coordinate tap is not supported on macOS",
157
+ hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
158
+ )
159
+ #endif
160
+ }
161
+
162
+ func keyboardAvoidingDragPoints(
163
+ app: XCUIApplication,
164
+ x: Double,
165
+ y: Double,
166
+ x2: Double,
167
+ y2: Double
168
+ ) -> DragPoints {
169
+ let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
170
+ #if os(iOS)
171
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
172
+ return original
173
+ }
174
+ let minX = min(x, x2)
175
+ let minY = min(y, y2)
176
+ let gestureBounds = CGRect(
177
+ x: CGFloat(minX),
178
+ y: CGFloat(minY),
179
+ width: CGFloat(max(abs(x2 - x), 1)),
180
+ height: CGFloat(max(abs(y2 - y), 1))
181
+ )
182
+ guard gestureBounds.intersects(keyboardFrame) else {
183
+ return original
184
+ }
185
+
186
+ let appFrame = onScreenWindowFrame(app: app)
187
+ guard !appFrame.isEmpty else {
188
+ return original
189
+ }
190
+
191
+ let padding: Double = 12
192
+ let targetMaxY = Double(keyboardFrame.minY) - padding
193
+ let currentMaxY = max(y, y2)
194
+ let shift = currentMaxY - targetMaxY
195
+ guard shift > 0 else {
196
+ return original
197
+ }
198
+
199
+ let adjustedY = y - shift
200
+ let adjustedY2 = y2 - shift
201
+ guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
202
+ return original
203
+ }
204
+
205
+ NSLog(
206
+ "AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
207
+ x,
208
+ y,
209
+ x2,
210
+ y2,
211
+ x,
212
+ adjustedY,
213
+ x2,
214
+ adjustedY2,
215
+ Double(keyboardFrame.minY)
216
+ )
217
+ return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
218
+ #else
219
+ return original
220
+ #endif
221
+ }
222
+
223
+ func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
224
+ let appFrame = app.frame
225
+ let referenceFrame = resolvedTouchReferenceFrame(app: app, appFrame: appFrame)
226
+ let originX = appFrame.isEmpty ? referenceFrame.minX : appFrame.minX
227
+ let originY = appFrame.isEmpty ? referenceFrame.minY : appFrame.minY
228
+ return TouchVisualizationFrame(
229
+ x: originX + x,
230
+ y: originY + y,
231
+ referenceWidth: referenceFrame.width,
232
+ referenceHeight: referenceFrame.height
233
+ )
234
+ }
235
+
236
+ func resolvedDragVisualizationFrame(
237
+ app: XCUIApplication,
238
+ x: Double,
239
+ y: Double,
240
+ x2: Double,
241
+ y2: Double
242
+ ) -> DragVisualizationFrame {
243
+ let start = resolvedTouchVisualizationFrame(app: app, x: x, y: y)
244
+ let end = resolvedTouchVisualizationFrame(app: app, x: x2, y: y2)
245
+ return DragVisualizationFrame(
246
+ x: start.x,
247
+ y: start.y,
248
+ x2: end.x,
249
+ y2: end.y,
250
+ referenceWidth: start.referenceWidth,
251
+ referenceHeight: start.referenceHeight
252
+ )
253
+ }
254
+
255
+ func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
256
+ let resolved = resolveRunnerWindow(app: app)
257
+ if resolved.window != nil {
258
+ return frameAvoidingKeyboard(app: app, frame: resolved.frame)
259
+ }
260
+ if !appFrame.isEmpty {
261
+ return frameAvoidingKeyboard(app: app, frame: appFrame)
262
+ }
263
+ return CGRect(x: 0, y: 0, width: 0, height: 0)
264
+ }
265
+
266
+ private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
267
+ #if os(iOS)
268
+ guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
269
+ return frame
270
+ }
271
+ let intersection = frame.intersection(keyboardFrame)
272
+ guard !intersection.isNull && intersection.height > 0 else {
273
+ return frame
274
+ }
275
+ let keyboardCoverage = intersection.width / max(frame.width, 1)
276
+ guard keyboardCoverage >= 0.5 else {
277
+ return frame
278
+ }
279
+ let safeHeight = keyboardFrame.minY - frame.minY
280
+ guard safeHeight >= frame.height * 0.25 else {
281
+ return frame
282
+ }
283
+ return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
284
+ #else
285
+ return frame
286
+ #endif
287
+ }
288
+
289
+ func axFreeSynthesizedDragPlan(
290
+ app: XCUIApplication,
291
+ x: Double,
292
+ y: Double,
293
+ x2: Double,
294
+ y2: Double,
295
+ context: SynthesizedCoordinateContext? = nil
296
+ ) -> SynthesizedDragPlan? {
297
+ #if os(iOS)
298
+ let context = context ?? synthesizedCoordinateContext(
299
+ app: app,
300
+ policy: synthesizedGesturePolicy(.synthesizedDrag)
301
+ )
302
+ guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite,
303
+ let context
304
+ else {
305
+ return nil
306
+ }
307
+ let points = keyboardAvoidingSynthesizedDragPoints(
308
+ app: app,
309
+ x: x,
310
+ y: y,
311
+ x2: x2,
312
+ y2: y2,
313
+ context: context
314
+ )
315
+ return SynthesizedDragPlan(
316
+ points: points,
317
+ context: context
318
+ )
319
+ #else
320
+ return nil
321
+ #endif
322
+ }
323
+
324
+ func axFreeDragVisualizationFrame(
325
+ x: Double,
326
+ y: Double,
327
+ x2: Double,
328
+ y2: Double,
329
+ referenceFrame: CGRect
330
+ ) -> DragVisualizationFrame {
331
+ return DragVisualizationFrame(
332
+ x: x,
333
+ y: y,
334
+ x2: x2,
335
+ y2: y2,
336
+ referenceWidth: Double(referenceFrame.width),
337
+ referenceHeight: Double(referenceFrame.height)
338
+ )
339
+ }
340
+
341
+ func logSynthesizedDispatch(
342
+ kind: String,
343
+ start: CGPoint,
344
+ end: CGPoint?,
345
+ referenceFrame: CGRect,
346
+ orientation: Int
347
+ ) {
348
+ if let end {
349
+ NSLog(
350
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=%@ start=(%.1f,%.1f) end=(%.1f,%.1f) reference=(%.1f,%.1f,%.1f,%.1f) orientation=%d",
351
+ kind,
352
+ Double(start.x),
353
+ Double(start.y),
354
+ Double(end.x),
355
+ Double(end.y),
356
+ Double(referenceFrame.origin.x),
357
+ Double(referenceFrame.origin.y),
358
+ Double(referenceFrame.width),
359
+ Double(referenceFrame.height),
360
+ orientation
361
+ )
362
+ return
363
+ }
364
+ NSLog(
365
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=%@ point=(%.1f,%.1f) reference=(%.1f,%.1f,%.1f,%.1f) orientation=%d",
366
+ kind,
367
+ Double(start.x),
368
+ Double(start.y),
369
+ Double(referenceFrame.origin.x),
370
+ Double(referenceFrame.origin.y),
371
+ Double(referenceFrame.width),
372
+ Double(referenceFrame.height),
373
+ orientation
374
+ )
375
+ }
376
+
377
+ func synthesizedCoordinateContext(
378
+ app: XCUIApplication,
379
+ policy: SynthesizedGesturePolicy
380
+ ) -> SynthesizedCoordinateContext? {
381
+ #if os(iOS)
382
+ let health = runnerAccessibilityHealth
383
+ let resolved = resolveRunnerWindow(app: app)
384
+ guard let window = resolved.window else {
385
+ return nil
386
+ }
387
+ let referenceFrame = resolved.frame
388
+ guard referenceFrame.width.isFinite, referenceFrame.height.isFinite,
389
+ referenceFrame.width > 0, referenceFrame.height > 0
390
+ else {
391
+ return nil
392
+ }
393
+ return SynthesizedCoordinateContext(
394
+ referenceFrame: referenceFrame,
395
+ resolvedWindow: window,
396
+ keyboardPolicy: policy.keyboardPolicy,
397
+ fallbackPolicy: policy.fallbackPolicy,
398
+ accessibilityHealth: health
399
+ )
400
+ #else
401
+ return nil
402
+ #endif
403
+ }
404
+
405
+
406
+ func keyboardAvoidingSynthesizedDragPoints(
407
+ app: XCUIApplication,
408
+ x: Double,
409
+ y: Double,
410
+ x2: Double,
411
+ y2: Double,
412
+ context: SynthesizedCoordinateContext
413
+ ) -> DragPoints {
414
+ #if os(iOS)
415
+ guard context.allowsKeyboardProbe else {
416
+ return DragPoints(x: x, y: y, x2: x2, y2: y2)
417
+ }
418
+ return keyboardAvoidingDragPoints(app: app, x: x, y: y, x2: x2, y2: y2)
419
+ #else
420
+ return DragPoints(x: x, y: y, x2: x2, y2: y2)
421
+ #endif
422
+ }
423
+
424
+ func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
425
+ if performTvRemoteSwipeIfAvailable(direction: direction) {
426
+ let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
427
+ let midX = frame.midX
428
+ let midY = frame.midY
429
+ return DragVisualizationFrame(
430
+ x: midX,
431
+ y: midY,
432
+ x2: midX,
433
+ y2: midY,
434
+ referenceWidth: frame.width,
435
+ referenceHeight: frame.height
436
+ )
437
+ }
438
+ return nil
439
+ }
440
+
441
+ private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
442
+ switch direction {
443
+ case "up":
444
+ return pressTvRemote(.up)
445
+ case "down":
446
+ return pressTvRemote(.down)
447
+ case "left":
448
+ return pressTvRemote(.left)
449
+ case "right":
450
+ return pressTvRemote(.right)
451
+ default:
452
+ return false
453
+ }
454
+ }
455
+
456
+ func plannedGestureValidationError(_ plan: RunnerGesturePlan) -> String? {
457
+ guard plan.topology == "single" || plan.topology == "two" else {
458
+ return "planned gesture topology must be single or two"
459
+ }
460
+ let supportedIntent = plan.topology == "single"
461
+ ? plan.intent == "fling" || plan.intent == "pan"
462
+ : plan.intent == "pan" || plan.intent == "pinch" || plan.intent == "rotate"
463
+ || plan.intent == "transform"
464
+ guard supportedIntent else { return "planned gesture has unsupported intent for its topology" }
465
+ if plan.topology == "single" {
466
+ guard plan.executionProfile == "endpoint-hold" || plan.executionProfile == "timed-pan" else {
467
+ return "single-pointer gesture requires a supported execution profile"
468
+ }
469
+ } else if plan.executionProfile != nil {
470
+ return "multi-touch gesture cannot define a single-pointer execution profile"
471
+ }
472
+ guard plan.durationMs.isFinite, plan.durationMs >= 16, plan.durationMs <= 10_000 else {
473
+ return "planned gesture durationMs must be between 16 and 10000"
474
+ }
475
+ let viewport = plan.viewport
476
+ guard viewport.x.isFinite, viewport.y.isFinite, viewport.width.isFinite,
477
+ viewport.height.isFinite, viewport.width > 0, viewport.height > 0
478
+ else {
479
+ return "planned gesture viewport must be finite and positive"
480
+ }
481
+ let expectedPointerCount = plan.topology == "single" ? 1 : 2
482
+ guard plan.pointers.count == expectedPointerCount else {
483
+ return "planned gesture pointer count does not match topology"
484
+ }
485
+ for (index, pointer) in plan.pointers.enumerated() where pointer.pointerId != index {
486
+ return "planned gesture requires ordered pointer ids"
487
+ }
488
+ let firstSamples = plan.pointers[0].samples
489
+ guard firstSamples.count >= 2 else { return "planned pointer paths require at least two samples" }
490
+ for pointer in plan.pointers {
491
+ guard pointer.samples.count == firstSamples.count else {
492
+ return "planned pointer paths require matching samples"
493
+ }
494
+ var previousOffset = -1.0
495
+ for (index, sample) in pointer.samples.enumerated() {
496
+ guard sample.offsetMs.isFinite,
497
+ sample.offsetMs == firstSamples[index].offsetMs,
498
+ sample.offsetMs > previousOffset
499
+ else {
500
+ return "planned pointer sample offsets must match and strictly increase"
501
+ }
502
+ let point = sample.point
503
+ guard point.x.isFinite, point.y.isFinite,
504
+ point.x >= viewport.x,
505
+ point.x <= viewport.x + viewport.width,
506
+ point.y >= viewport.y,
507
+ point.y <= viewport.y + viewport.height
508
+ else {
509
+ return "planned pointer sample lies outside the viewport"
510
+ }
511
+ previousOffset = sample.offsetMs
512
+ }
513
+ guard pointer.samples.first?.offsetMs == 0,
514
+ pointer.samples.last?.offsetMs == plan.durationMs
515
+ else {
516
+ return "planned pointer paths must start at 0 and end at durationMs"
517
+ }
518
+ }
519
+ if plan.topology == "two" {
520
+ guard let firstStart = firstSamples.first?.point,
521
+ let secondStart = plan.pointers[1].samples.first?.point,
522
+ hypot(firstStart.x - secondStart.x, firstStart.y - secondStart.y) > 0
523
+ else {
524
+ return "planned pointer paths require a positive initial span"
525
+ }
526
+ }
527
+ return nil
528
+ }
529
+
530
+ func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
531
+ plan.topology == "single" && plan.executionProfile == "endpoint-hold"
532
+ ? .fastSwipe
533
+ : .sampled
534
+ }
535
+
536
+ func sampledPlannedGesture(
537
+ app: XCUIApplication,
538
+ plan: RunnerGesturePlan
539
+ ) -> RunnerInteractionOutcome {
540
+ #if os(iOS)
541
+ let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
542
+
543
+
544
+ let frame = CGRect(
545
+ x: plan.viewport.x,
546
+ y: plan.viewport.y,
547
+ width: plan.viewport.width,
548
+ height: plan.viewport.height
549
+ )
550
+ let pointerSamples: [[[String: NSNumber]]] = plan.pointers.map { pointer in
551
+ pointer.samples.map { sample in
552
+ let point = CoordinateSpaceRotation.native(
553
+ point: CGPoint(x: sample.point.x, y: sample.point.y),
554
+ in: frame,
555
+ interfaceOrientation: orientation
556
+ )
557
+ return [
558
+ "x": NSNumber(value: Double(point.x)),
559
+ "y": NSNumber(value: Double(point.y)),
560
+ "offsetMs": NSNumber(value: sample.offsetMs),
561
+ ]
562
+ }
563
+ }
564
+ let resolvedWindow = resolveRunnerWindow(app: app).window
565
+ if let message = RunnerSynthesizedGesture.synthesizeGesture(
566
+ withApplication: app,
567
+ resolvedWindow: resolvedWindow,
568
+ pointerSamples: pointerSamples
569
+ ) {
570
+ return .unsupported(
571
+ message: message,
572
+ hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode if this persists."
573
+ )
574
+ }
575
+ return .performed
576
+ #elseif os(tvOS)
577
+ return .unsupported(
578
+ message: "two-finger gestures are not supported on tvOS",
579
+ hint: "tvOS has no touch input; use remote-driven navigation."
580
+ )
581
+ #elseif os(visionOS)
582
+ return .unsupported(
583
+ message: "two-finger touch gestures are not supported on visionOS",
584
+ hint: "The current XCTest synthesizer supports iOS and iPadOS touch simulators only."
585
+ )
586
+ #else
587
+ return .unsupported(
588
+ message: "two-finger gestures are not supported on macOS",
589
+ hint: "macOS automation has no multi-touch input; run on an iOS simulator."
590
+ )
591
+ #endif
592
+ }
593
+
594
+ }
@@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN
10
10
  //
11
11
  // Each caller additionally resolves whichever XCSynthesizedEventRecord init
12
12
  // overload and XCPointerEventPath factory/mutator selectors its own event kind
13
- // needs: gesture uses the 2-arg `initWithName:interfaceOrientation:` plus
13
+ // needs: gesture uses `initWithName:displayID:interfaceOrientation:` plus
14
14
  // touch-path selectors (`initForTouchAtPoint:offset:`, `moveToPoint:atOffset:`,
15
15
  // `liftUpAtOffset:`); text entry uses the 1-arg `initWithName:` plus text-input
16
16
  // selectors (`initForTextInput`, `typeText:atOffset:typingSpeed:shouldRedact:`,
@@ -30,6 +30,16 @@ typedef void (*RunnerMsgSendSetInteger)(id, SEL, NSInteger);
30
30
  typedef void (*RunnerMsgSendAddPath)(id, SEL, id);
31
31
  typedef BOOL (*RunnerMsgSendSynthesize)(id, SEL, NSError **);
32
32
 
33
+ // Why the display hosting the target app could not be named. Callers branch on this rather than
34
+ // on the message, so a refusal keeps its reason across the language boundary (#2728).
35
+ typedef NS_ENUM(NSUInteger, RunnerApplicationScreenFailure) {
36
+ RunnerApplicationScreenFailureNone,
37
+ // No window with a usable frame, so nothing resolves and its screen would only report main.
38
+ RunnerApplicationScreenFailureUnresolvedWindow,
39
+ // A resolved window whose screen or display identity the XCTest runtime would not answer.
40
+ RunnerApplicationScreenFailureUnresolvedScreen,
41
+ };
42
+
33
43
  // Resolves the shared core described above against the live XCTest runtime.
34
44
  // Returns nil and populates `bridge` on success. Returns a non-nil
35
45
  // "private XCTest <surface> synthesis unavailable: ..." message and leaves
@@ -43,6 +53,30 @@ FOUNDATION_EXPORT NSString * _Nullable RunnerResolveXCTestEventBridge(
43
53
  RunnerXCTestEventBridge *bridge
44
54
  );
45
55
 
56
+ // Resolves the `XCUIScreen` hosting the application's window and that screen's `displayID`, which
57
+ // is the only display identity that follows a foldable app to whichever panel it currently lights.
58
+ // Reading the window frame must precede reading its screen: an unresolved element still reports the
59
+ // main screen. Never selects a screen by number or by position in `XCUIScreen.screens`.
60
+ // Returns YES and writes the screen to capture into `screen` and its identity into `displayID`.
61
+ // Otherwise returns NO, leaves both empty, and names the reason in `failure` so callers branch on
62
+ // the reason rather than on message text (#2728). Private KVC raises rather than returns nil when
63
+ // XCTest has no such value, so both reads are guarded here and surface as a typed failure instead
64
+ // of an exception reaching the caller.
65
+ FOUNDATION_EXPORT BOOL RunnerResolveApplicationScreen(
66
+ id application,
67
+ id _Nullable *screen,
68
+ NSUInteger *displayID,
69
+ RunnerApplicationScreenFailure *failure
70
+ );
71
+
72
+ // Reads the display ID off an already-resolved window. When a caller has resolved the app window
73
+ // for geometry, routing the synthesized gesture by this window keeps the record's display and the
74
+ // booked reference frame on the same window instead of re-walking `windows.firstMatch`.
75
+ FOUNDATION_EXPORT NSString * _Nullable RunnerResolveWindowDisplayID(
76
+ id window,
77
+ NSUInteger *displayID
78
+ );
79
+
46
80
  FOUNDATION_EXPORT NSString * _Nullable RunnerRequireClass(
47
81
  Class cls,
48
82
  NSString *className,