agent-device 0.17.2 → 0.17.4

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 (86) 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.4.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.4.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.4.manifest.json} +4 -4
  5. package/android-snapshot-helper/README.md +3 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.2.apk → agent-device-android-snapshot-helper-0.17.4.apk} +0 -0
  7. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.4.apk.sha256 +1 -0
  8. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.2.manifest.json → agent-device-android-snapshot-helper-0.17.4.manifest.json} +6 -6
  9. package/dist/src/123.js +1 -1
  10. package/dist/src/1231.js +1 -1
  11. package/dist/src/1352.js +1 -1
  12. package/dist/src/221.js +2 -2
  13. package/dist/src/2403.js +3 -3
  14. package/dist/src/2415.js +37 -29
  15. package/dist/src/4012.js +4 -0
  16. package/dist/src/5611.js +1 -0
  17. package/dist/src/5827.js +1 -0
  18. package/dist/src/6088.js +1 -0
  19. package/dist/src/6133.js +1 -0
  20. package/dist/src/7847.js +1 -1
  21. package/dist/src/9471.js +1 -1
  22. package/dist/src/9542.js +6 -3
  23. package/dist/src/9616.js +2 -1
  24. package/dist/src/9818.js +1 -1
  25. package/dist/src/android-snapshot-helper.d.ts +0 -1
  26. package/dist/src/android.js +5 -5
  27. package/dist/src/apple.js +1 -1
  28. package/dist/src/apps.js +10 -10
  29. package/dist/src/args.js +53 -14
  30. package/dist/src/batch.js +1 -1
  31. package/dist/src/cli-output.js +30 -0
  32. package/dist/src/cli.js +15 -16
  33. package/dist/src/command-surface.js +1 -1
  34. package/dist/src/contracts.d.ts +52 -0
  35. package/dist/src/contracts.js +1 -1
  36. package/dist/src/find.js +1 -1
  37. package/dist/src/generic.js +5 -5
  38. package/dist/src/index.d.ts +127 -8
  39. package/dist/src/index.js +1 -1
  40. package/dist/src/input-actions.js +1 -1
  41. package/dist/src/interaction.js +1 -1
  42. package/dist/src/react-native.js +1 -1
  43. package/dist/src/record-trace-recording.js +3 -3
  44. package/dist/src/selector-runtime.js +1 -1
  45. package/dist/src/selectors.d.ts +14 -0
  46. package/dist/src/selectors.js +1 -1
  47. package/dist/src/server.js +2 -2
  48. package/dist/src/session.js +8 -10
  49. package/dist/src/snapshot.js +2 -2
  50. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  51. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +14 -0
  52. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +297 -0
  53. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +327 -0
  54. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +122 -173
  55. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +153 -5
  56. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +171 -0
  57. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -10
  58. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +64 -18
  59. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +200 -0
  60. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +450 -0
  61. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +82 -178
  62. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +425 -0
  63. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +77 -6
  64. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +10 -1
  65. package/ios-runner/README.md +5 -5
  66. package/ios-runner/RUNNER_PROTOCOL.md +0 -1
  67. package/package.json +3 -3
  68. package/server.json +2 -2
  69. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.2.apk.sha256 +0 -1
  70. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.2.apk.sha256 +0 -1
  71. package/dist/src/1534.js +0 -1
  72. package/dist/src/1620.js +0 -1
  73. package/dist/src/1644.js +0 -2
  74. package/dist/src/2284.js +0 -1
  75. package/dist/src/2474.js +0 -1
  76. package/dist/src/2672.js +0 -1
  77. package/dist/src/3393.js +0 -1
  78. package/dist/src/5898.js +0 -1
  79. package/dist/src/695.js +0 -1
  80. package/dist/src/7455.js +0 -1
  81. package/dist/src/8173.js +0 -27
  82. package/dist/src/8407.js +0 -1
  83. package/dist/src/8699.js +0 -1
  84. package/dist/src/9238.js +0 -4
  85. package/dist/src/9673.js +0 -1
  86. package/dist/src/9974.js +0 -1
@@ -0,0 +1,171 @@
1
+ import XCTest
2
+
3
+ struct FlatSnapshotFilterNode {
4
+ let isRoot: Bool
5
+ let label: String
6
+ let identifier: String
7
+ let valueText: String?
8
+ let visible: Bool
9
+
10
+ var hasContent: Bool {
11
+ return !label.isEmpty || !identifier.isEmpty || valueText != nil
12
+ }
13
+
14
+ func matchesScope(_ scope: String) -> Bool {
15
+ let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
16
+ return haystack.localizedCaseInsensitiveContains(scope)
17
+ }
18
+ }
19
+
20
+ struct FlatSnapshotFilterDecision {
21
+ let include: Bool
22
+ let insideMatchedScope: Bool
23
+ }
24
+
25
+ extension RunnerTests {
26
+ func flatSnapshotFilterDecision(
27
+ _ node: FlatSnapshotFilterNode,
28
+ options: SnapshotOptions,
29
+ insideMatchedScope: Bool
30
+ ) -> FlatSnapshotFilterDecision {
31
+ let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
32
+ let scopeActive = scope?.isEmpty == false
33
+ let matchesScope: Bool
34
+ if scopeActive, let scope {
35
+ matchesScope = node.matchesScope(scope)
36
+ } else {
37
+ matchesScope = false
38
+ }
39
+ let nowInsideScope = insideMatchedScope || matchesScope
40
+
41
+ let include: Bool
42
+ if node.isRoot {
43
+ include = true
44
+ } else if scopeActive && !nowInsideScope {
45
+ include = false
46
+ } else if options.interactiveOnly && !node.visible {
47
+ include = false
48
+ } else {
49
+ include = true
50
+ }
51
+
52
+ return FlatSnapshotFilterDecision(include: include, insideMatchedScope: nowInsideScope)
53
+ }
54
+
55
+ func privateAXInteractiveCandidate(rawElementType: Int) -> Bool {
56
+ guard let type = flatSnapshotElementType(rawElementType: rawElementType) else {
57
+ return false
58
+ }
59
+ return interactiveTypes.contains(type) || Self.scrollContainerTypes.contains(type)
60
+ }
61
+
62
+ func flatSnapshotElementType(rawElementType: Int) -> XCUIElement.ElementType? {
63
+ guard let raw = UInt(exactly: rawElementType),
64
+ let type = XCUIElement.ElementType(rawValue: raw)
65
+ else {
66
+ return nil
67
+ }
68
+ return type
69
+ }
70
+
71
+ func testFlatSnapshotFilterDecisionMatrixCoversOptions() {
72
+ let visibleContent = FlatSnapshotFilterNode(
73
+ isRoot: false,
74
+ label: "Welcome back",
75
+ identifier: "",
76
+ valueText: nil,
77
+ visible: true
78
+ )
79
+ let hiddenInteractive = FlatSnapshotFilterNode(
80
+ isRoot: false,
81
+ label: "Hidden menu",
82
+ identifier: "",
83
+ valueText: nil,
84
+ visible: false
85
+ )
86
+ let decorative = FlatSnapshotFilterNode(
87
+ isRoot: false,
88
+ label: "",
89
+ identifier: "",
90
+ valueText: nil,
91
+ visible: true
92
+ )
93
+
94
+ XCTAssertTrue(
95
+ flatSnapshotFilterDecision(
96
+ visibleContent,
97
+ options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
98
+ insideMatchedScope: false
99
+ ).include
100
+ )
101
+ XCTAssertFalse(
102
+ flatSnapshotFilterDecision(
103
+ hiddenInteractive,
104
+ options: SnapshotOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false),
105
+ insideMatchedScope: false
106
+ ).include
107
+ )
108
+ XCTAssertTrue(
109
+ flatSnapshotFilterDecision(
110
+ decorative,
111
+ options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
112
+ insideMatchedScope: false
113
+ ).include
114
+ )
115
+ XCTAssertTrue(
116
+ flatSnapshotFilterDecision(
117
+ decorative,
118
+ options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false),
119
+ insideMatchedScope: false
120
+ ).include
121
+ )
122
+ }
123
+
124
+ func testFlatSnapshotFilterDecisionCarriesSubtreeScopeState() {
125
+ let scopeRoot = FlatSnapshotFilterNode(
126
+ isRoot: false,
127
+ label: "",
128
+ identifier: "homeScreen",
129
+ valueText: nil,
130
+ visible: true
131
+ )
132
+ let unmatchedDescendant = FlatSnapshotFilterNode(
133
+ isRoot: false,
134
+ label: "Post body without the scope text",
135
+ identifier: "",
136
+ valueText: nil,
137
+ visible: true
138
+ )
139
+ let options = SnapshotOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false)
140
+
141
+ let rootDecision = flatSnapshotFilterDecision(
142
+ scopeRoot,
143
+ options: options,
144
+ insideMatchedScope: false
145
+ )
146
+ XCTAssertTrue(rootDecision.include)
147
+ XCTAssertTrue(rootDecision.insideMatchedScope)
148
+
149
+ XCTAssertTrue(
150
+ flatSnapshotFilterDecision(
151
+ unmatchedDescendant,
152
+ options: options,
153
+ insideMatchedScope: rootDecision.insideMatchedScope
154
+ ).include
155
+ )
156
+ XCTAssertFalse(
157
+ flatSnapshotFilterDecision(
158
+ unmatchedDescendant,
159
+ options: options,
160
+ insideMatchedScope: false
161
+ ).include
162
+ )
163
+ }
164
+
165
+ func testPrivateAXInteractiveCandidatesPreserveBackendInputs() {
166
+ XCTAssertTrue(
167
+ privateAXInteractiveCandidate(rawElementType: Int(XCUIElement.ElementType.scrollView.rawValue)),
168
+ "private AX marks scroll containers as interactive candidates"
169
+ )
170
+ }
171
+ }
@@ -841,16 +841,6 @@ extension RunnerTests {
841
841
  #endif
842
842
  }
843
843
 
844
- func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
845
- let total = max(count, 1)
846
- let pause = max(pauseMs, 0)
847
- for idx in 0..<total {
848
- operation(idx)
849
- if idx < total - 1 && pause > 0 {
850
- sleepFor(pause / 1000.0)
851
- }
852
- }
853
- }
854
844
 
855
845
  func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
856
846
  if performTvRemoteSwipeIfAvailable(direction: direction) {
@@ -3,14 +3,12 @@
3
3
  enum CommandType: String, Codable {
4
4
  case tap
5
5
  case mouseClick
6
- case tapSeries
7
6
  case longPress
8
- case interactionFrame
9
7
  case drag
10
- case dragSeries
11
8
  case remotePress
12
9
  case type
13
10
  case swipe
11
+ case scroll
14
12
  case findText
15
13
  case querySelector
16
14
  case readText
@@ -26,6 +24,7 @@ enum CommandType: String, Codable {
26
24
  case keyboardReturn
27
25
  case alert
28
26
  case pinch
27
+ case sequence
29
28
  case rotateGesture
30
29
  case transformGesture
31
30
  case recordStart
@@ -68,16 +67,16 @@ extension CommandType {
68
67
  var traits: CommandTraits {
69
68
  switch self {
70
69
  // Interaction commands: require the foreground-guard + stabilization preflight.
71
- // tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling
72
- // of keyboardDismiss all three were missing from the historical switch (drift the
73
- // table now prevents) and are classified as interactions here.
74
- case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe,
70
+ // keyboardReturn is the sibling of keyboardDismiss (missing from the historical switch —
71
+ // drift the table now prevents). .scroll is the fused frame-resolve + drag scroll; same
72
+ // classification as .drag. .sequence is the fused multi-step gesture batch.
73
+ case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll,
75
74
  .back, .backInApp, .backSystem, .rotate, .appSwitcher,
76
- .keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture:
75
+ .keyboardDismiss, .keyboardReturn, .pinch, .sequence, .rotateGesture, .transformGesture:
77
76
  return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
78
77
 
79
78
  // Read-only reads: eligible for the session-invalidating retry.
80
- case .interactionFrame, .findText, .readText, .snapshot:
79
+ case .findText, .readText, .snapshot:
81
80
  return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)
82
81
 
83
82
  // Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
@@ -123,17 +122,14 @@ struct Command: Codable {
123
122
  let y: Double?
124
123
  let button: String?
125
124
  let remoteButton: String?
126
- let count: Double?
127
- let intervalMs: Double?
128
- let doubleTap: Bool?
129
- let pauseMs: Double?
130
- let pattern: String?
131
125
  let x2: Double?
132
126
  let y2: Double?
133
127
  let dx: Double?
134
128
  let dy: Double?
135
129
  let durationMs: Double?
136
130
  let direction: String?
131
+ let amount: Double?
132
+ let pixels: Double?
137
133
  let orientation: String?
138
134
  let scale: Double?
139
135
  let degrees: Double?
@@ -142,12 +138,39 @@ struct Command: Codable {
142
138
  let fps: Int?
143
139
  let quality: Int?
144
140
  let interactiveOnly: Bool?
145
- let compact: Bool?
146
141
  let depth: Int?
147
142
  let scope: String?
148
143
  let raw: Bool?
149
144
  let fullscreen: Bool?
150
145
  let synthesized: Bool?
146
+ let steps: [SequenceStep]?
147
+ }
148
+
149
+ /// One allowlisted coordinate gesture step inside a fused `sequence` command.
150
+ /// `kind` is decoded as a raw String (not an enum) so the runner can return a clear
151
+ /// INVALID_ARGS for an unknown kind instead of a generic decode failure.
152
+ struct SequenceStep: Codable {
153
+ let kind: String
154
+ let x: Double?
155
+ let y: Double?
156
+ let x2: Double?
157
+ let y2: Double?
158
+ let durationMs: Double?
159
+ let pauseMs: Double?
160
+ /// For `tap`/`drag` steps on iOS non-tv: use the synthesized HID fast path instead of the
161
+ /// drag-based XCUICoordinate path, matching the individual command behavior.
162
+ let synthesized: Bool?
163
+ }
164
+
165
+ /// Per-step result for a `sequence` response. `ok:false` carries the failing step's
166
+ /// errorCode/errorMessage; execution stops at the first failed step.
167
+ struct SequenceStepResult: Codable {
168
+ let ok: Bool
169
+ let kind: String
170
+ let errorCode: String?
171
+ let errorMessage: String?
172
+ let gestureStartUptimeMs: Double?
173
+ let gestureEndUptimeMs: Double?
151
174
  }
152
175
 
153
176
  struct Response: Codable {
@@ -162,6 +185,18 @@ struct Response: Codable {
162
185
  }
163
186
  }
164
187
 
188
+ extension Response {
189
+ // The daemon pairs this gesture-clock anchor with its own receipt time to map
190
+ // gesture uptimes onto wall-clock for the recording touch overlay. Error responses
191
+ // carry no anchor so the daemon falls back instead of pairing a stale value.
192
+ func stampingCurrentUptimeMs(_ value: Double) -> Response {
193
+ guard ok else { return self }
194
+ var payload = data ?? DataPayload()
195
+ payload.currentUptimeMs = value
196
+ return Response(ok: ok, data: payload, error: error)
197
+ }
198
+ }
199
+
165
200
  struct DataPayload: Codable {
166
201
  let message: String?
167
202
  let text: String?
@@ -169,6 +204,7 @@ struct DataPayload: Codable {
169
204
  let items: [String]?
170
205
  let nodes: [SnapshotNode]?
171
206
  let truncated: Bool?
207
+ let snapshotQuality: SnapshotQuality?
172
208
  let gestureStartUptimeMs: Double?
173
209
  let gestureEndUptimeMs: Double?
174
210
  let x: Double?
@@ -177,7 +213,7 @@ struct DataPayload: Codable {
177
213
  let y2: Double?
178
214
  let referenceWidth: Double?
179
215
  let referenceHeight: Double?
180
- let currentUptimeMs: Double?
216
+ var currentUptimeMs: Double?
181
217
  let commandId: String?
182
218
  let lifecycleState: String?
183
219
  let lifecycleCommand: String?
@@ -195,6 +231,9 @@ struct DataPayload: Codable {
195
231
  let gestureFallbackHint: String?
196
232
  let runnerFatal: Bool?
197
233
  let runnerFatalReason: String?
234
+ let completedSteps: Int?
235
+ let failedStepIndex: Int?
236
+ let sequenceResults: [SequenceStepResult]?
198
237
 
199
238
  init(
200
239
  message: String? = nil,
@@ -203,6 +242,7 @@ struct DataPayload: Codable {
203
242
  items: [String]? = nil,
204
243
  nodes: [SnapshotNode]? = nil,
205
244
  truncated: Bool? = nil,
245
+ snapshotQuality: SnapshotQuality? = nil,
206
246
  gestureStartUptimeMs: Double? = nil,
207
247
  gestureEndUptimeMs: Double? = nil,
208
248
  x: Double? = nil,
@@ -228,7 +268,10 @@ struct DataPayload: Codable {
228
268
  gestureFallbackMessage: String? = nil,
229
269
  gestureFallbackHint: String? = nil,
230
270
  runnerFatal: Bool? = nil,
231
- runnerFatalReason: String? = nil
271
+ runnerFatalReason: String? = nil,
272
+ completedSteps: Int? = nil,
273
+ failedStepIndex: Int? = nil,
274
+ sequenceResults: [SequenceStepResult]? = nil
232
275
  ) {
233
276
  self.message = message
234
277
  self.text = text
@@ -236,6 +279,7 @@ struct DataPayload: Codable {
236
279
  self.items = items
237
280
  self.nodes = nodes
238
281
  self.truncated = truncated
282
+ self.snapshotQuality = snapshotQuality
239
283
  self.gestureStartUptimeMs = gestureStartUptimeMs
240
284
  self.gestureEndUptimeMs = gestureEndUptimeMs
241
285
  self.x = x
@@ -262,6 +306,9 @@ struct DataPayload: Codable {
262
306
  self.gestureFallbackHint = gestureFallbackHint
263
307
  self.runnerFatal = runnerFatal
264
308
  self.runnerFatalReason = runnerFatalReason
309
+ self.completedSteps = completedSteps
310
+ self.failedStepIndex = failedStepIndex
311
+ self.sequenceResults = sequenceResults
265
312
  }
266
313
  }
267
314
 
@@ -303,7 +350,6 @@ struct SnapshotNode: Codable {
303
350
 
304
351
  struct SnapshotOptions {
305
352
  let interactiveOnly: Bool
306
- let compact: Bool
307
353
  let depth: Int?
308
354
  let scope: String?
309
355
  let raw: Bool
@@ -0,0 +1,200 @@
1
+ import XCTest
2
+
3
+ // Swift port of buildScrollGesturePlan from src/core/scroll-gesture.ts.
4
+ //
5
+ // This is a deliberate two-place invariant: the daemon keeps the TS implementation (for Android,
6
+ // recording, and reported-pixels), and the runner places the gesture with this Swift copy. The
7
+ // parity test vectors at the bottom of this file mirror src/core/__tests__/scroll-gesture.test.ts —
8
+ // if you change the math in either language, update the other and both vector sets.
9
+ //
10
+ // All inputs here are positive (reference dims, travel, center), so Swift's `.rounded()`
11
+ // (half away from zero) matches JS `Math.round` (half up) on every value computed below.
12
+
13
+ struct RunnerScrollGesturePlan {
14
+ let x1: Double
15
+ let y1: Double
16
+ let x2: Double
17
+ let y2: Double
18
+ let travelPixels: Double
19
+ }
20
+
21
+ private let runnerDefaultScrollAmount = 0.6
22
+ private let runnerDefaultEdgePaddingFraction = 0.05
23
+
24
+ func runnerScrollGesturePlan(
25
+ direction: String,
26
+ amount: Double?,
27
+ pixels: Double?,
28
+ referenceWidth: Double,
29
+ referenceHeight: Double
30
+ ) -> RunnerScrollGesturePlan? {
31
+ // Mirror the TS INVALID_ARGS contract: non-positive or non-finite amount/pixels are rejected
32
+ // rather than clamped into a journaled 1px scroll. The daemon validates before sending, so
33
+ // this only triggers for non-daemon wire clients.
34
+ if let amount, !(amount.isFinite && amount > 0) { return nil }
35
+ if let pixels, !(pixels.isFinite && pixels > 0) { return nil }
36
+ let axisLength = (direction == "up" || direction == "down") ? referenceHeight : referenceWidth
37
+ let requestedAmount = amount ?? runnerDefaultScrollAmount
38
+ let requestedPixels: Double =
39
+ pixels.map { max(1, $0.rounded()) } ?? (axisLength * requestedAmount).rounded()
40
+ let edgePadding = max(1, (axisLength * runnerDefaultEdgePaddingFraction).rounded())
41
+ let maxTravelPixels = max(1, axisLength - edgePadding * 2)
42
+ let travelPixels = max(1, min(requestedPixels, maxTravelPixels))
43
+ let halfTravel = (travelPixels / 2).rounded()
44
+ let centerX = (referenceWidth / 2).rounded()
45
+ let centerY = (referenceHeight / 2).rounded()
46
+
47
+ func plan(_ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> RunnerScrollGesturePlan {
48
+ RunnerScrollGesturePlan(x1: x1, y1: y1, x2: x2, y2: y2, travelPixels: travelPixels)
49
+ }
50
+
51
+ switch direction {
52
+ case "up":
53
+ return plan(centerX, centerY - halfTravel, centerX, centerY + halfTravel)
54
+ case "down":
55
+ return plan(centerX, centerY + halfTravel, centerX, centerY - halfTravel)
56
+ case "left":
57
+ return plan(centerX - halfTravel, centerY, centerX + halfTravel, centerY)
58
+ case "right":
59
+ return plan(centerX + halfTravel, centerY, centerX - halfTravel, centerY)
60
+ default:
61
+ return nil
62
+ }
63
+ }
64
+
65
+ extension RunnerTests {
66
+ // Cross-language parity vectors mirroring src/core/__tests__/scroll-gesture.test.ts. Keep these
67
+ // in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift.
68
+
69
+ func testRunnerScrollGesturePlanMapsRelativeAmount() throws {
70
+ let plan = try XCTUnwrap(
71
+ runnerScrollGesturePlan(
72
+ direction: "down",
73
+ amount: 0.5,
74
+ pixels: nil,
75
+ referenceWidth: 400,
76
+ referenceHeight: 800
77
+ )
78
+ )
79
+ XCTAssertEqual(plan.x1, 200)
80
+ XCTAssertEqual(plan.y1, 600)
81
+ XCTAssertEqual(plan.x2, 200)
82
+ XCTAssertEqual(plan.y2, 200)
83
+ XCTAssertEqual(plan.travelPixels, 400)
84
+ }
85
+
86
+ func testRunnerScrollGesturePlanPixelsDown() throws {
87
+ // 300x600, down, pixels 120 -> (150,360)->(150,240), travel 120.
88
+ let plan = try XCTUnwrap(
89
+ runnerScrollGesturePlan(
90
+ direction: "down",
91
+ amount: nil,
92
+ pixels: 120,
93
+ referenceWidth: 300,
94
+ referenceHeight: 600
95
+ )
96
+ )
97
+ XCTAssertEqual(plan.x1, 150)
98
+ XCTAssertEqual(plan.y1, 360)
99
+ XCTAssertEqual(plan.x2, 150)
100
+ XCTAssertEqual(plan.y2, 240)
101
+ XCTAssertEqual(plan.travelPixels, 120)
102
+ }
103
+
104
+ func testRunnerScrollGesturePlanClampsAmountAboveOne() throws {
105
+ // 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40).
106
+ let plan = try XCTUnwrap(
107
+ runnerScrollGesturePlan(
108
+ direction: "down",
109
+ amount: 2,
110
+ pixels: nil,
111
+ referenceWidth: 400,
112
+ referenceHeight: 800
113
+ )
114
+ )
115
+ XCTAssertEqual(plan.x1, 200)
116
+ XCTAssertEqual(plan.y1, 760)
117
+ XCTAssertEqual(plan.x2, 200)
118
+ XCTAssertEqual(plan.y2, 40)
119
+ XCTAssertEqual(plan.travelPixels, 720)
120
+ }
121
+
122
+ func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
123
+ // 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
124
+ // ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
125
+ let plan = try XCTUnwrap(
126
+ runnerScrollGesturePlan(
127
+ direction: "down",
128
+ amount: nil,
129
+ pixels: 10,
130
+ referenceWidth: 2,
131
+ referenceHeight: 2
132
+ )
133
+ )
134
+ XCTAssertEqual(plan.x1, 1)
135
+ XCTAssertEqual(plan.y1, 2)
136
+ XCTAssertEqual(plan.x2, 1)
137
+ XCTAssertEqual(plan.y2, 0)
138
+ XCTAssertEqual(plan.travelPixels, 1)
139
+ }
140
+
141
+ func testRunnerScrollGesturePlanClampsToSafeBand() throws {
142
+ // 300x600, right, pixels 500 clamps travel to the safe band (270).
143
+ let plan = try XCTUnwrap(
144
+ runnerScrollGesturePlan(
145
+ direction: "right",
146
+ amount: nil,
147
+ pixels: 500,
148
+ referenceWidth: 300,
149
+ referenceHeight: 600
150
+ )
151
+ )
152
+ XCTAssertEqual(plan.x1, 285)
153
+ XCTAssertEqual(plan.x2, 15)
154
+ XCTAssertEqual(plan.y1, 300)
155
+ XCTAssertEqual(plan.y2, 300)
156
+ XCTAssertEqual(plan.travelPixels, 270)
157
+ }
158
+
159
+ func testRunnerScrollGesturePlanRejectsUnknownDirection() {
160
+ XCTAssertNil(
161
+ runnerScrollGesturePlan(
162
+ direction: "sideways",
163
+ amount: nil,
164
+ pixels: 100,
165
+ referenceWidth: 300,
166
+ referenceHeight: 600
167
+ )
168
+ )
169
+ }
170
+
171
+ func testRunnerScrollGesturePlanRejectsInvalidAmountAndPixels() {
172
+ XCTAssertNil(
173
+ runnerScrollGesturePlan(
174
+ direction: "down",
175
+ amount: 0,
176
+ pixels: nil,
177
+ referenceWidth: 300,
178
+ referenceHeight: 600
179
+ )
180
+ )
181
+ XCTAssertNil(
182
+ runnerScrollGesturePlan(
183
+ direction: "down",
184
+ amount: nil,
185
+ pixels: -10,
186
+ referenceWidth: 300,
187
+ referenceHeight: 600
188
+ )
189
+ )
190
+ XCTAssertNil(
191
+ runnerScrollGesturePlan(
192
+ direction: "down",
193
+ amount: .infinity,
194
+ pixels: nil,
195
+ referenceWidth: 300,
196
+ referenceHeight: 600
197
+ )
198
+ )
199
+ }
200
+ }