agent-device 0.17.2 → 0.17.3

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 (83) hide show
  1. package/README.md +2 -2
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.17.2.apk → agent-device-android-multitouch-helper-0.17.3.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.3.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.17.2.manifest.json → agent-device-android-multitouch-helper-0.17.3.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.2.apk → agent-device-android-snapshot-helper-0.17.3.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.3.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.2.manifest.json → agent-device-android-snapshot-helper-0.17.3.manifest.json} +6 -6
  8. package/dist/src/123.js +1 -1
  9. package/dist/src/1231.js +1 -1
  10. package/dist/src/1352.js +1 -1
  11. package/dist/src/221.js +2 -2
  12. package/dist/src/2403.js +3 -3
  13. package/dist/src/2415.js +36 -29
  14. package/dist/src/5611.js +1 -0
  15. package/dist/src/5827.js +1 -0
  16. package/dist/src/6088.js +1 -0
  17. package/dist/src/6133.js +1 -0
  18. package/dist/src/7847.js +1 -1
  19. package/dist/src/9471.js +1 -1
  20. package/dist/src/9542.js +6 -3
  21. package/dist/src/9616.js +2 -1
  22. package/dist/src/9818.js +1 -1
  23. package/dist/src/android-snapshot-helper.d.ts +0 -1
  24. package/dist/src/android.js +5 -5
  25. package/dist/src/apple.js +1 -1
  26. package/dist/src/apps.js +10 -10
  27. package/dist/src/args.js +53 -14
  28. package/dist/src/batch.js +1 -1
  29. package/dist/src/cli-output.js +29 -0
  30. package/dist/src/cli.js +15 -16
  31. package/dist/src/command-surface.js +1 -1
  32. package/dist/src/contracts.d.ts +52 -0
  33. package/dist/src/contracts.js +1 -1
  34. package/dist/src/find.js +1 -1
  35. package/dist/src/generic.js +5 -5
  36. package/dist/src/index.d.ts +111 -8
  37. package/dist/src/index.js +1 -1
  38. package/dist/src/input-actions.js +1 -1
  39. package/dist/src/interaction.js +1 -1
  40. package/dist/src/react-native.js +1 -1
  41. package/dist/src/record-trace-recording.js +3 -3
  42. package/dist/src/selector-runtime.js +1 -1
  43. package/dist/src/selectors.d.ts +14 -0
  44. package/dist/src/selectors.js +1 -1
  45. package/dist/src/server.js +2 -2
  46. package/dist/src/session.js +8 -10
  47. package/dist/src/snapshot.js +2 -2
  48. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  49. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +14 -0
  50. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +297 -0
  51. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +328 -0
  52. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +121 -172
  53. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +153 -5
  54. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +171 -0
  55. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -10
  56. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +64 -18
  57. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +200 -0
  58. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +406 -0
  59. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +82 -169
  60. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +425 -0
  61. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +77 -6
  62. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +10 -1
  63. package/ios-runner/README.md +5 -5
  64. package/ios-runner/RUNNER_PROTOCOL.md +0 -1
  65. package/package.json +3 -3
  66. package/server.json +2 -2
  67. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.2.apk.sha256 +0 -1
  68. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.2.apk.sha256 +0 -1
  69. package/dist/src/1534.js +0 -1
  70. package/dist/src/1620.js +0 -1
  71. package/dist/src/1644.js +0 -2
  72. package/dist/src/2284.js +0 -1
  73. package/dist/src/2474.js +0 -1
  74. package/dist/src/2672.js +0 -1
  75. package/dist/src/3393.js +0 -1
  76. package/dist/src/5898.js +0 -1
  77. package/dist/src/695.js +0 -1
  78. package/dist/src/7455.js +0 -1
  79. package/dist/src/8173.js +0 -27
  80. package/dist/src/8407.js +0 -1
  81. package/dist/src/8699.js +0 -1
  82. package/dist/src/9673.js +0 -1
  83. package/dist/src/9974.js +0 -1
@@ -0,0 +1,328 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ private static let privateAXSnapshotMaxNodes = 5_000
5
+ /// Deep React Native trees make the AX server reject bulk snapshot requests outright with
6
+ /// kAXErrorIllegalArgument once the requested depth crosses a tree-size-dependent limit
7
+ /// (observed between depth 56 and 64 on the Bluesky Home feed; the limit moves with live
8
+ /// content). Retrying the same request at a shallower depth succeeds, so on failure we walk
9
+ /// this ladder instead of giving up. Capped at 4 attempts to bound worst-case latency on
10
+ /// apps where the AX surface is genuinely unavailable.
11
+ static let privateAXSnapshotDepthLadder = [56, 40, 24, 12]
12
+
13
+ func privateAXSnapshotCapture(
14
+ app: XCUIApplication,
15
+ options: SnapshotOptions
16
+ ) -> SnapshotBackendCapture? {
17
+ #if os(iOS) && targetEnvironment(simulator)
18
+ let requestedDepth = options.depth ?? 64
19
+ var attemptDepths = [requestedDepth]
20
+ attemptDepths.append(
21
+ contentsOf: Self.privateAXSnapshotDepthLadder.filter { $0 < requestedDepth }
22
+ )
23
+ var response: [String: Any] = [:]
24
+ var effectiveDepth = requestedDepth
25
+ var lastError = "unknown private AX snapshot failure"
26
+ for depth in attemptDepths {
27
+ response = RunnerAXSnapshotBridge.snapshotTree(
28
+ for: app,
29
+ maxDepth: depth,
30
+ maxNodes: Self.privateAXSnapshotMaxNodes
31
+ )
32
+ if response["ok"] as? Bool == true {
33
+ effectiveDepth = depth
34
+ break
35
+ }
36
+ lastError = response["error"] as? String ?? lastError
37
+ NSLog(
38
+ "AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_DEPTH_RETRY depth=%ld error=%@",
39
+ depth,
40
+ lastError
41
+ )
42
+ }
43
+ guard response["ok"] as? Bool == true else {
44
+ NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_FAILED=%@", lastError)
45
+ return nil
46
+ }
47
+ guard let root = response["root"] as? [String: Any] else {
48
+ NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_FAILED=missing root")
49
+ return nil
50
+ }
51
+
52
+ // The public windows query backing safeSnapshotViewport can fail on the same apps that
53
+ // need this fallback, degrading to an infinite viewport that marks off-screen content
54
+ // (e.g. closed drawer menus at negative x) as visible and tappable. The private root's
55
+ // own frame is the reliable screen rect here.
56
+ var viewport = safeSnapshotViewport(app: app)
57
+ let rootFrame = privateAXRect(root["frame"])
58
+ if viewport.isInfinite || viewport.isNull || viewport.isEmpty, !rootFrame.isEmpty {
59
+ viewport = rootFrame
60
+ }
61
+ var nodes: [SnapshotNode] = []
62
+ appendPrivateAXNode(
63
+ root,
64
+ to: &nodes,
65
+ options: options,
66
+ viewport: viewport,
67
+ depth: 0,
68
+ parentIndex: nil,
69
+ insideMatchedScope: false
70
+ )
71
+ if nodes.count <= 1 {
72
+ NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_SPARSE=%ld", nodes.count)
73
+ return nil
74
+ }
75
+
76
+ let depthLimited = effectiveDepth < requestedDepth
77
+ NSLog(
78
+ "AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_USED nodes=%ld depth=%ld",
79
+ nodes.count,
80
+ effectiveDepth
81
+ )
82
+ return SnapshotBackendCapture(
83
+ payload: DataPayload(nodes: nodes, truncated: (response["truncated"] as? Bool) == true),
84
+ effectiveDepth: depthLimited ? effectiveDepth : nil
85
+ )
86
+ #else
87
+ return nil
88
+ #endif
89
+ }
90
+
91
+ private func appendPrivateAXNode(
92
+ _ rawNode: [String: Any],
93
+ to nodes: inout [SnapshotNode],
94
+ options: SnapshotOptions,
95
+ viewport: CGRect,
96
+ depth: Int,
97
+ parentIndex: Int?,
98
+ insideMatchedScope: Bool
99
+ ) {
100
+ if let limit = options.depth, depth > limit { return }
101
+
102
+ let rect = privateAXRect(rawNode["frame"])
103
+ let label = privateAXString(rawNode["label"])
104
+ let identifier = privateAXString(rawNode["identifier"])
105
+ let value = privateAXString(rawNode["value"])
106
+ let rawType = privateAXInt(rawNode["type"]) ?? 0
107
+ let typeName = elementTypeName(rawElementType: rawType)
108
+ let enabled = privateAXBool(rawNode["enabled"]) ?? true
109
+ let visible = isVisibleInViewport(rect, viewport)
110
+ let interactiveCandidate = privateAXInteractiveCandidate(rawElementType: rawType)
111
+ let filterDecision = flatSnapshotFilterDecision(
112
+ FlatSnapshotFilterNode(
113
+ isRoot: parentIndex == nil,
114
+ label: label,
115
+ identifier: identifier,
116
+ valueText: value.isEmpty ? nil : value,
117
+ visible: visible
118
+ ),
119
+ options: options,
120
+ insideMatchedScope: insideMatchedScope
121
+ )
122
+ let include = filterDecision.include
123
+ let nowInsideScope = filterDecision.insideMatchedScope
124
+
125
+ let currentIndex: Int?
126
+ if include {
127
+ currentIndex = nodes.count
128
+ nodes.append(
129
+ SnapshotNode(
130
+ index: nodes.count,
131
+ type: typeName,
132
+ label: label.isEmpty ? nil : label,
133
+ identifier: identifier.isEmpty ? nil : identifier,
134
+ value: value.isEmpty ? nil : value,
135
+ rect: snapshotRect(from: rect),
136
+ enabled: enabled,
137
+ focused: privateAXBool(rawNode["focused"]) == true ? true : nil,
138
+ selected: privateAXBool(rawNode["selected"]) == true ? true : nil,
139
+ hittable: visible && enabled && interactiveCandidate,
140
+ depth: depth,
141
+ parentIndex: parentIndex,
142
+ hiddenContentAbove: nil,
143
+ hiddenContentBelow: nil
144
+ )
145
+ )
146
+ } else {
147
+ currentIndex = parentIndex
148
+ }
149
+
150
+ guard let children = rawNode["children"] as? [[String: Any]] else {
151
+ return
152
+ }
153
+ for child in children {
154
+ appendPrivateAXNode(
155
+ child,
156
+ to: &nodes,
157
+ options: options,
158
+ viewport: viewport,
159
+ depth: depth + 1,
160
+ parentIndex: currentIndex,
161
+ insideMatchedScope: nowInsideScope
162
+ )
163
+ }
164
+ }
165
+
166
+ private func elementTypeName(rawElementType: Int) -> String {
167
+ if let type = flatSnapshotElementType(rawElementType: rawElementType) {
168
+ return elementTypeName(type)
169
+ }
170
+ return "Element(\(rawElementType))"
171
+ }
172
+
173
+ private func privateAXString(_ value: Any?) -> String {
174
+ guard let value else { return "" }
175
+ if let string = value as? String {
176
+ return string.trimmingCharacters(in: .whitespacesAndNewlines)
177
+ }
178
+ return String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
179
+ }
180
+
181
+ private func privateAXInt(_ value: Any?) -> Int? {
182
+ if let value = value as? Int { return value }
183
+ if let value = value as? NSNumber { return value.intValue }
184
+ return nil
185
+ }
186
+
187
+ private func privateAXBool(_ value: Any?) -> Bool? {
188
+ if let value = value as? Bool { return value }
189
+ if let value = value as? NSNumber { return value.boolValue }
190
+ return nil
191
+ }
192
+
193
+ private func privateAXRect(_ value: Any?) -> CGRect {
194
+ guard let frame = value as? [String: Any] else {
195
+ return .zero
196
+ }
197
+ return CGRect(
198
+ x: privateAXDouble(frame["x"]) ?? 0,
199
+ y: privateAXDouble(frame["y"]) ?? 0,
200
+ width: privateAXDouble(frame["width"]) ?? 0,
201
+ height: privateAXDouble(frame["height"]) ?? 0
202
+ )
203
+ }
204
+
205
+ private func privateAXDouble(_ value: Any?) -> Double? {
206
+ if let value = value as? Double { return value }
207
+ if let value = value as? NSNumber { return value.doubleValue }
208
+ return nil
209
+ }
210
+ }
211
+
212
+ // MARK: - In-bundle unit tests
213
+
214
+ extension RunnerTests {
215
+ func testPrivateAXScopeSelectsSubtreeNotMatchingLabels() {
216
+ let tree: [String: Any] = [
217
+ "type": 1, "label": "App",
218
+ "children": [
219
+ [
220
+ "type": 9, "identifier": "homeScreen",
221
+ "children": [
222
+ ["type": 48, "label": "Post body without the scope text", "children": []]
223
+ ],
224
+ ],
225
+ ["type": 9, "label": "unrelated sibling", "children": []],
226
+ ],
227
+ ]
228
+ var nodes: [SnapshotNode] = []
229
+ appendPrivateAXNode(
230
+ tree,
231
+ to: &nodes,
232
+ options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false),
233
+ viewport: .infinite,
234
+ depth: 0,
235
+ parentIndex: nil,
236
+ insideMatchedScope: false
237
+ )
238
+
239
+ let labels = nodes.compactMap { $0.label ?? $0.identifier }
240
+ XCTAssertTrue(labels.contains("homeScreen"))
241
+ // Descendants of the matched scope are included even when they do not contain the text.
242
+ XCTAssertTrue(labels.contains("Post body without the scope text"))
243
+ XCTAssertFalse(labels.contains("unrelated sibling"))
244
+ }
245
+
246
+ func testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer() {
247
+ let tree: [String: Any] = [
248
+ "type": Int(XCUIElement.ElementType.application.rawValue),
249
+ "label": "Blue Sky",
250
+ "frame": ["x": 0, "y": 0, "width": 390, "height": 844],
251
+ "children": [
252
+ [
253
+ "type": Int(XCUIElement.ElementType.scrollView.rawValue),
254
+ "frame": ["x": 0, "y": 0, "width": 390, "height": 844],
255
+ "children": [
256
+ [
257
+ "type": Int(XCUIElement.ElementType.image.rawValue),
258
+ "label": "Callstack",
259
+ "frame": ["x": 145, "y": 104, "width": 100, "height": 100],
260
+ "children": [],
261
+ ],
262
+ [
263
+ "type": Int(XCUIElement.ElementType.staticText.rawValue),
264
+ "label": "Welcome back",
265
+ "frame": ["x": 32, "y": 260, "width": 326, "height": 32],
266
+ "children": [],
267
+ ],
268
+ [
269
+ "type": Int(XCUIElement.ElementType.textField.rawValue),
270
+ "label": "Email",
271
+ "identifier": "login.email",
272
+ "frame": ["x": 32, "y": 348, "width": 326, "height": 48],
273
+ "children": [],
274
+ ],
275
+ [
276
+ "type": Int(XCUIElement.ElementType.secureTextField.rawValue),
277
+ "label": "Password",
278
+ "identifier": "login.password",
279
+ "frame": ["x": 32, "y": 412, "width": 326, "height": 48],
280
+ "children": [],
281
+ ],
282
+ [
283
+ "type": Int(XCUIElement.ElementType.button.rawValue),
284
+ "label": "Sign in",
285
+ "identifier": "login.submit",
286
+ "frame": ["x": 32, "y": 492, "width": 326, "height": 52],
287
+ "children": [],
288
+ ],
289
+ [
290
+ "type": Int(XCUIElement.ElementType.link.rawValue),
291
+ "label": "Forgot password?",
292
+ "frame": ["x": 128, "y": 568, "width": 134, "height": 32],
293
+ "children": [],
294
+ ],
295
+ [
296
+ "type": Int(XCUIElement.ElementType.button.rawValue),
297
+ "label": "Admin settings",
298
+ "frame": ["x": -260, "y": 184, "width": 220, "height": 44],
299
+ "children": [],
300
+ ],
301
+ [
302
+ "type": Int(XCUIElement.ElementType.other.rawValue),
303
+ "frame": ["x": 16, "y": 184, "width": 220, "height": 44],
304
+ "children": [],
305
+ ],
306
+ ],
307
+ ]
308
+ ],
309
+ ]
310
+ var nodes: [SnapshotNode] = []
311
+ appendPrivateAXNode(
312
+ tree,
313
+ to: &nodes,
314
+ options: SnapshotOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false),
315
+ viewport: CGRect(x: 0, y: 0, width: 390, height: 844),
316
+ depth: 0,
317
+ parentIndex: nil,
318
+ insideMatchedScope: false
319
+ )
320
+
321
+ let labels = nodes.compactMap { $0.label }
322
+ XCTAssertEqual(
323
+ labels,
324
+ ["Blue Sky", "Callstack", "Welcome back", "Email", "Password", "Sign in", "Forgot password?"]
325
+ )
326
+ XCTAssertFalse(labels.contains("Admin settings"))
327
+ }
328
+ }
@@ -17,7 +17,7 @@ extension RunnerTests {
17
17
  min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
18
18
  }
19
19
 
20
- private func coordinateDragHoldDuration() -> TimeInterval {
20
+ func coordinateDragHoldDuration() -> TimeInterval {
21
21
  0.050
22
22
  }
23
23
 
@@ -55,27 +55,6 @@ extension RunnerTests {
55
55
  }
56
56
  }
57
57
 
58
- private func performDragSeries(
59
- count: Int,
60
- pauseMs: Double,
61
- pattern: String,
62
- points: DragPoints,
63
- _ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome
64
- ) -> RunnerInteractionOutcome {
65
- var outcome = RunnerInteractionOutcome.performed
66
- runSeries(count: count, pauseMs: pauseMs) { idx in
67
- guard case .performed = outcome else {
68
- return
69
- }
70
- let reverse = pattern == "ping-pong" && (idx % 2 == 1)
71
- let startX = reverse ? points.x2 : points.x
72
- let startY = reverse ? points.y2 : points.y
73
- let endX = reverse ? points.x : points.x2
74
- let endY = reverse ? points.y : points.y2
75
- outcome = drag(startX, startY, endX, endY)
76
- }
77
- return outcome
78
- }
79
58
 
80
59
  /// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
81
60
  /// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
@@ -84,7 +63,7 @@ extension RunnerTests {
84
63
  ///
85
64
  /// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
86
65
  /// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior.
87
- private func performGesture(
66
+ func performGesture(
88
67
  _ app: XCUIApplication,
89
68
  idleTimeout: Bool = true,
90
69
  _ action: () -> RunnerInteractionOutcome
@@ -244,6 +223,8 @@ extension RunnerTests {
244
223
  }
245
224
 
246
225
  func executeUptime() -> Response {
226
+ // Placeholder value: the transport layer (jsonResponse) overwrites currentUptimeMs with a
227
+ // fresher send-time stamp on every ok response; kept so direct callers still get a value.
247
228
  Response(
248
229
  ok: true,
249
230
  data: DataPayload(currentUptimeMs: currentUptimeMs())
@@ -580,42 +561,6 @@ extension RunnerTests {
580
561
  } catch {
581
562
  return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
582
563
  }
583
- case .tapSeries:
584
- guard let x = command.x, let y = command.y else {
585
- return Response(ok: false, error: ErrorPayload(message: "tapSeries requires x and y"))
586
- }
587
- let count = max(Int(command.count ?? 1), 1)
588
- let intervalMs = max(command.intervalMs ?? 0, 0)
589
- let doubleTap = command.doubleTap ?? false
590
- let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
591
- if doubleTap {
592
- let (timing, outcome) = performGesture(activeApp) {
593
- var outcome = RunnerInteractionOutcome.performed
594
- runSeries(count: count, pauseMs: intervalMs) { _ in
595
- if case .performed = outcome {
596
- outcome = doubleTapAt(app: activeApp, x: x, y: y)
597
- }
598
- }
599
- return outcome
600
- }
601
- if let response = unsupportedResponse(for: outcome) {
602
- return response
603
- }
604
- return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
605
- }
606
- let (timing, outcome) = performGesture(activeApp) {
607
- var outcome = RunnerInteractionOutcome.performed
608
- runSeries(count: count, pauseMs: intervalMs) { _ in
609
- if case .performed = outcome {
610
- outcome = tapAt(app: activeApp, x: x, y: y)
611
- }
612
- }
613
- return outcome
614
- }
615
- if let response = unsupportedResponse(for: outcome) {
616
- return response
617
- }
618
- return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
619
564
  case .longPress:
620
565
  guard let x = command.x, let y = command.y else {
621
566
  return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
@@ -633,114 +578,63 @@ extension RunnerTests {
633
578
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
634
579
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
635
580
  }
636
- let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
637
- let dragFrame = resolvedDragVisualizationFrame(
638
- app: activeApp,
639
- x: dragPoints.x,
640
- y: dragPoints.y,
641
- x2: dragPoints.x2,
642
- y2: dragPoints.y2
581
+ return executeDragGesture(
582
+ activeApp: activeApp,
583
+ x: x,
584
+ y: y,
585
+ x2: x2,
586
+ y2: y2,
587
+ durationMs: command.durationMs,
588
+ synthesized: command.synthesized == true,
589
+ message: "dragged"
643
590
  )
644
- var fallback: GestureFallback?
645
- if command.synthesized == true {
646
- let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
647
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
648
- synthesizedDragAt(
649
- app: activeApp,
650
- x: dragPoints.x,
651
- y: dragPoints.y,
652
- x2: dragPoints.x2,
653
- y2: dragPoints.y2,
654
- durationMs: durationMs
591
+ case .scroll:
592
+ // Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame via
593
+ // resolvedTouchReferenceFrame, computes drag endpoints with the Swift port of
594
+ // buildScrollGesturePlan, then runs the same non-synthesized drag path scroll's drag used.
595
+ guard let direction = command.direction,
596
+ direction == "up" || direction == "down" || direction == "left" || direction == "right"
597
+ else {
598
+ return Response(
599
+ ok: false,
600
+ error: ErrorPayload(
601
+ code: "INVALID_ARGS",
602
+ message: "scroll requires direction up|down|left|right"
655
603
  )
656
- }
657
- if case .performed = outcome {
658
- return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
659
- }
660
- fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
661
- }
662
- let holdDuration = command.synthesized == true
663
- ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
664
- : coordinateDragHoldDuration()
665
- let (timing, outcome) = performGesture(activeApp) {
666
- dragAt(
667
- app: activeApp,
668
- x: dragPoints.x,
669
- y: dragPoints.y,
670
- x2: dragPoints.x2,
671
- y2: dragPoints.y2,
672
- holdDuration: holdDuration
673
604
  )
674
605
  }
675
- if let response = unsupportedResponse(for: outcome) {
676
- return response
677
- }
678
- return gestureResponse(
679
- message: "dragged",
680
- timing: timing,
681
- frame: .drag(dragFrame),
682
- fallback: fallback
683
- )
684
- case .dragSeries:
685
- guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
686
- return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
687
- }
688
- let count = max(Int(command.count ?? 1), 1)
689
- let pauseMs = max(command.pauseMs ?? 0, 0)
690
- let pattern = command.pattern ?? "one-way"
691
- if pattern != "one-way" && pattern != "ping-pong" {
692
- return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
693
- }
694
- let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
695
- var fallback: GestureFallback?
696
- if command.synthesized == true {
697
- let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
698
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
699
- performDragSeries(
700
- count: count,
701
- pauseMs: pauseMs,
702
- pattern: pattern,
703
- points: dragPoints
704
- ) { startX, startY, endX, endY in
705
- synthesizedDragAt(
706
- app: activeApp,
707
- x: startX,
708
- y: startY,
709
- x2: endX,
710
- y2: endY,
711
- durationMs: durationMs
712
- )
713
- }
714
- }
715
- if case .performed = outcome {
716
- return gestureResponse(message: "drag series", timing: timing)
717
- }
718
- fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
606
+ let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
607
+ guard frame.width > 0, frame.height > 0 else {
608
+ return Response(
609
+ ok: false,
610
+ error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
611
+ )
719
612
  }
720
- let holdDuration = command.synthesized == true
721
- ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
722
- : coordinateDragHoldDuration()
723
- let (timing, outcome) = performGesture(activeApp) {
724
- performDragSeries(
725
- count: count,
726
- pauseMs: pauseMs,
727
- pattern: pattern,
728
- points: dragPoints
729
- ) { startX, startY, endX, endY in
730
- dragAt(
731
- app: activeApp,
732
- x: startX,
733
- y: startY,
734
- x2: endX,
735
- y2: endY,
736
- holdDuration: holdDuration
613
+ guard let plan = runnerScrollGesturePlan(
614
+ direction: direction,
615
+ amount: command.amount,
616
+ pixels: command.pixels,
617
+ referenceWidth: frame.width,
618
+ referenceHeight: frame.height
619
+ ) else {
620
+ return Response(
621
+ ok: false,
622
+ error: ErrorPayload(
623
+ code: "INVALID_ARGS",
624
+ message: "scroll could not compute a gesture plan"
737
625
  )
738
- }
739
- }
740
- if let response = unsupportedResponse(for: outcome) {
741
- return response
626
+ )
742
627
  }
743
- return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
628
+ return executeDragGesture(
629
+ activeApp: activeApp,
630
+ x: frame.minX + plan.x1,
631
+ y: frame.minY + plan.y1,
632
+ x2: frame.minX + plan.x2,
633
+ y2: frame.minY + plan.y2,
634
+ durationMs: nil,
635
+ synthesized: false,
636
+ message: "scrolled"
637
+ )
744
638
  case .remotePress:
745
639
  guard let button = tvRemoteButton(from: command.remoteButton) else {
746
640
  return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
@@ -759,17 +653,6 @@ extension RunnerTests {
759
653
  response = executeTypeCommand(activeApp: activeApp, command: command)
760
654
  }
761
655
  return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
762
- case .interactionFrame:
763
- let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
764
- return Response(
765
- ok: true,
766
- data: DataPayload(
767
- x: frame.minX,
768
- y: frame.minY,
769
- referenceWidth: frame.width,
770
- referenceHeight: frame.height
771
- )
772
- )
773
656
  case .swipe:
774
657
  guard let direction = command.direction else {
775
658
  return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
@@ -808,7 +691,6 @@ extension RunnerTests {
808
691
  case .snapshot:
809
692
  let options = SnapshotOptions(
810
693
  interactiveOnly: command.interactiveOnly ?? false,
811
- compact: command.compact ?? false,
812
694
  depth: command.depth,
813
695
  scope: command.scope,
814
696
  raw: command.raw ?? false
@@ -960,6 +842,8 @@ extension RunnerTests {
960
842
  return response
961
843
  }
962
844
  return gestureResponse(message: "pinched", timing: timing)
845
+ case .sequence:
846
+ return executeSequence(command: command, activeApp: activeApp)
963
847
  case .rotateGesture:
964
848
  guard let degrees = command.degrees, degrees.isFinite else {
965
849
  return Response(ok: false, error: ErrorPayload(message: "rotateGesture requires degrees"))
@@ -1023,6 +907,71 @@ extension RunnerTests {
1023
907
  }
1024
908
  }
1025
909
 
910
+ /// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
911
+ /// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
912
+ /// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
913
+ /// gestureResponse(.drag). `.scroll` always passes synthesized: false, pinning the same
914
+ /// non-synthesized drag path scroll's drag used today.
915
+ private func executeDragGesture(
916
+ activeApp: XCUIApplication,
917
+ x: Double,
918
+ y: Double,
919
+ x2: Double,
920
+ y2: Double,
921
+ durationMs: Double?,
922
+ synthesized: Bool,
923
+ message: String
924
+ ) -> Response {
925
+ let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
926
+ let dragFrame = resolvedDragVisualizationFrame(
927
+ app: activeApp,
928
+ x: dragPoints.x,
929
+ y: dragPoints.y,
930
+ x2: dragPoints.x2,
931
+ y2: dragPoints.y2
932
+ )
933
+ var fallback: GestureFallback?
934
+ if synthesized {
935
+ let durationMs = min(max(durationMs ?? 250, 16), 10000)
936
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
937
+ synthesizedDragAt(
938
+ app: activeApp,
939
+ x: dragPoints.x,
940
+ y: dragPoints.y,
941
+ x2: dragPoints.x2,
942
+ y2: dragPoints.y2,
943
+ durationMs: durationMs
944
+ )
945
+ }
946
+ if case .performed = outcome {
947
+ return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
948
+ }
949
+ fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
950
+ }
951
+ let holdDuration = synthesized
952
+ ? synthesizedSwipeFallbackHoldDuration(durationMs: durationMs ?? 250)
953
+ : coordinateDragHoldDuration()
954
+ let (timing, outcome) = performGesture(activeApp) {
955
+ dragAt(
956
+ app: activeApp,
957
+ x: dragPoints.x,
958
+ y: dragPoints.y,
959
+ x2: dragPoints.x2,
960
+ y2: dragPoints.y2,
961
+ holdDuration: holdDuration
962
+ )
963
+ }
964
+ if let response = unsupportedResponse(for: outcome) {
965
+ return response
966
+ }
967
+ return gestureResponse(
968
+ message: message,
969
+ timing: timing,
970
+ frame: .drag(dragFrame),
971
+ fallback: fallback
972
+ )
973
+ }
974
+
1026
975
  private func currentXCTestFailureCount() -> Int {
1027
976
  return testRun?.failureCount ?? 0
1028
977
  }