agent-device 0.17.1 → 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 (97) hide show
  1. package/README.md +3 -3
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.17.1.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.1.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.1.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.1.manifest.json → agent-device-android-snapshot-helper-0.17.3.manifest.json} +6 -6
  8. package/dist/src/123.js +1 -0
  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 -0
  13. package/dist/src/2415.js +36 -30
  14. package/dist/src/4778.js +1 -1
  15. package/dist/src/5611.js +1 -0
  16. package/dist/src/5827.js +1 -0
  17. package/dist/src/6088.js +1 -0
  18. package/dist/src/6133.js +1 -0
  19. package/dist/src/7556.js +1 -1
  20. package/dist/src/7847.js +1 -1
  21. package/dist/src/8806.js +3 -3
  22. package/dist/src/9010.js +1 -0
  23. package/dist/src/9238.js +3 -3
  24. package/dist/src/9471.js +1 -1
  25. package/dist/src/9542.js +6 -3
  26. package/dist/src/9616.js +2 -0
  27. package/dist/src/9818.js +1 -1
  28. package/dist/src/android-adb.d.ts +12 -6
  29. package/dist/src/android-adb.js +1 -1
  30. package/dist/src/android-snapshot-helper.d.ts +15 -8
  31. package/dist/src/android.js +5 -5
  32. package/dist/src/apple.js +1 -1
  33. package/dist/src/apps.js +10 -10
  34. package/dist/src/args.js +53 -14
  35. package/dist/src/batch.d.ts +16 -4
  36. package/dist/src/batch.js +1 -1
  37. package/dist/src/cli-output.js +29 -0
  38. package/dist/src/cli.js +17 -42
  39. package/dist/src/command-surface.js +1 -1
  40. package/dist/src/contracts.d.ts +80 -4
  41. package/dist/src/contracts.js +1 -1
  42. package/dist/src/find.js +1 -1
  43. package/dist/src/finders.d.ts +3 -1
  44. package/dist/src/generic.js +7 -7
  45. package/dist/src/index.d.ts +250 -79
  46. package/dist/src/index.js +1 -1
  47. package/dist/src/input-actions.js +1 -1
  48. package/dist/src/interaction.js +1 -1
  49. package/dist/src/internal/png-worker.d.ts +26 -0
  50. package/dist/src/internal/png-worker.js +1 -0
  51. package/dist/src/metro.d.ts +3 -1
  52. package/dist/src/react-native.js +1 -1
  53. package/dist/src/record-trace-recording.js +3 -3
  54. package/dist/src/remote-config.d.ts +33 -7
  55. package/dist/src/selector-runtime.js +1 -1
  56. package/dist/src/selectors.d.ts +17 -3
  57. package/dist/src/selectors.js +1 -1
  58. package/dist/src/server.js +2 -2
  59. package/dist/src/session.js +8 -11
  60. package/dist/src/snapshot.js +2 -2
  61. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  62. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +14 -0
  63. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +297 -0
  64. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +328 -0
  65. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +121 -172
  66. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +153 -5
  67. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +171 -0
  68. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -10
  69. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +64 -18
  70. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +200 -0
  71. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +406 -0
  72. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +82 -169
  73. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +425 -0
  74. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +77 -6
  75. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +10 -1
  76. package/ios-runner/README.md +5 -5
  77. package/ios-runner/RUNNER_PROTOCOL.md +0 -1
  78. package/package.json +8 -5
  79. package/server.json +2 -2
  80. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.1.apk.sha256 +0 -1
  81. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.1.apk.sha256 +0 -1
  82. package/dist/src/1998.js +0 -1
  83. package/dist/src/2805.js +0 -1
  84. package/dist/src/4057.js +0 -1
  85. package/dist/src/5310.js +0 -1
  86. package/dist/src/5792.js +0 -1
  87. package/dist/src/6085.js +0 -1
  88. package/dist/src/6232.js +0 -1
  89. package/dist/src/7455.js +0 -1
  90. package/dist/src/8020.js +0 -1
  91. package/dist/src/8502.js +0 -1
  92. package/dist/src/8699.js +0 -1
  93. package/dist/src/940.js +0 -1
  94. package/dist/src/9404.js +0 -1
  95. package/dist/src/9533.js +0 -1
  96. package/dist/src/command-metadata.js +0 -1
  97. /package/dist/src/{1393.js → 3675.js} +0 -0
@@ -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
+ }
@@ -0,0 +1,406 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Sequence command
5
+
6
+ /// Hard cap mirrored from the daemon (MAX_RUNNER_SEQUENCE_STEPS). Keeps the worst-case
7
+ /// retained journal response well under the 16KB cap and bounds the lost-response window.
8
+ var maxSequenceSteps: Int { 20 }
9
+
10
+ /// Allowlisted step kinds. Validated on both sides so an unsupported kind is rejected with a
11
+ /// clear INVALID_ARGS naming the step index, executing nothing.
12
+ private var sequenceableStepKinds: Set<String> { ["tap", "doubleTap", "longPress", "drag"] }
13
+
14
+ /// Per-step outcome carried by `assembleSequenceExecution`. The timing is captured by the
15
+ /// executor closure (via performGesture) so ordering/stop-on-failure stay device-free testable.
16
+ struct SequenceStepOutcome {
17
+ let outcome: RunnerInteractionOutcome
18
+ let gestureStartUptimeMs: Double
19
+ let gestureEndUptimeMs: Double
20
+ }
21
+
22
+ func executeSequence(command: Command, activeApp: XCUIApplication) -> Response {
23
+ guard let steps = command.steps, !steps.isEmpty else {
24
+ return sequenceInvalidArgs("sequence requires at least one step")
25
+ }
26
+ guard steps.count <= maxSequenceSteps else {
27
+ return sequenceInvalidArgs(
28
+ "sequence accepts at most \(maxSequenceSteps) steps, received \(steps.count)"
29
+ )
30
+ }
31
+ for (index, step) in steps.enumerated() {
32
+ if let error = validateSequenceStep(step, index: index) {
33
+ return error
34
+ }
35
+ }
36
+
37
+ // Touch frame resolves from the first step's coords so recording-gestures works unchanged.
38
+ let firstStep = steps[0]
39
+ let firstFrame = (firstStep.x != nil && firstStep.y != nil)
40
+ ? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!)
41
+ : nil
42
+
43
+ let execution = assembleSequenceExecution(steps: steps) { _, step in
44
+ performSequenceStep(step, activeApp: activeApp)
45
+ }
46
+ return sequenceResponse(execution: execution, touchFrame: firstFrame)
47
+ }
48
+
49
+ /// Pure, device-free assembler: runs each step in order via `perform`, stops at the first
50
+ /// `.unsupported` outcome, and assembles the DataPayload (completedSteps, optional
51
+ /// failedStepIndex, per-step results, top-level gesture timing spanning first..last executed).
52
+ /// Steps after the failed index are never invoked and produce no result entries, so
53
+ /// results.count == completedSteps + (failedStepIndex != nil ? 1 : 0).
54
+ func assembleSequenceExecution(
55
+ steps: [SequenceStep],
56
+ perform: (Int, SequenceStep) -> SequenceStepOutcome
57
+ ) -> SequenceExecutionResult {
58
+ var results: [SequenceStepResult] = []
59
+ var completedSteps = 0
60
+ var failedStepIndex: Int?
61
+ var gestureStartUptimeMs: Double?
62
+ var gestureEndUptimeMs: Double?
63
+
64
+ for (index, step) in steps.enumerated() {
65
+ let stepOutcome = perform(index, step)
66
+ if gestureStartUptimeMs == nil {
67
+ gestureStartUptimeMs = stepOutcome.gestureStartUptimeMs
68
+ }
69
+ gestureEndUptimeMs = stepOutcome.gestureEndUptimeMs
70
+
71
+ switch stepOutcome.outcome {
72
+ case .performed:
73
+ results.append(
74
+ SequenceStepResult(
75
+ ok: true,
76
+ kind: step.kind,
77
+ errorCode: nil,
78
+ errorMessage: nil,
79
+ gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs,
80
+ gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs
81
+ )
82
+ )
83
+ completedSteps += 1
84
+ case .unsupported(let message, _):
85
+ results.append(
86
+ SequenceStepResult(
87
+ ok: false,
88
+ kind: step.kind,
89
+ errorCode: "UNSUPPORTED_OPERATION",
90
+ errorMessage: message,
91
+ gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs,
92
+ gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs
93
+ )
94
+ )
95
+ failedStepIndex = index
96
+ return SequenceExecutionResult(
97
+ results: results,
98
+ completedSteps: completedSteps,
99
+ failedStepIndex: failedStepIndex,
100
+ gestureStartUptimeMs: gestureStartUptimeMs,
101
+ gestureEndUptimeMs: gestureEndUptimeMs
102
+ )
103
+ }
104
+ }
105
+
106
+ return SequenceExecutionResult(
107
+ results: results,
108
+ completedSteps: completedSteps,
109
+ failedStepIndex: nil,
110
+ gestureStartUptimeMs: gestureStartUptimeMs,
111
+ gestureEndUptimeMs: gestureEndUptimeMs
112
+ )
113
+ }
114
+
115
+ struct SequenceExecutionResult {
116
+ let results: [SequenceStepResult]
117
+ let completedSteps: Int
118
+ let failedStepIndex: Int?
119
+ let gestureStartUptimeMs: Double?
120
+ let gestureEndUptimeMs: Double?
121
+ }
122
+
123
+ // MARK: - Step validation / execution
124
+
125
+ private func validateSequenceStep(_ step: SequenceStep, index: Int) -> Response? {
126
+ guard sequenceableStepKinds.contains(step.kind) else {
127
+ return sequenceInvalidArgs(
128
+ "sequence step \(index) has unsupported kind \"\(step.kind)\"; allowed: tap, doubleTap, longPress, drag"
129
+ )
130
+ }
131
+ guard let x = step.x, let y = step.y, x.isFinite, y.isFinite else {
132
+ return sequenceInvalidArgs("sequence step \(index) (\(step.kind)) requires finite x and y")
133
+ }
134
+ if step.kind == "drag" {
135
+ guard let x2 = step.x2, let y2 = step.y2, x2.isFinite, y2.isFinite else {
136
+ return sequenceInvalidArgs("sequence step \(index) (drag) requires finite x2 and y2")
137
+ }
138
+ }
139
+ return nil
140
+ }
141
+
142
+ private func performSequenceStep(
143
+ _ step: SequenceStep,
144
+ activeApp: XCUIApplication
145
+ ) -> SequenceStepOutcome {
146
+ let x = step.x ?? 0
147
+ let y = step.y ?? 0
148
+ // Synthesized HID tap fast path mirrors the individual `tap` command (idleTimeout:false, with
149
+ // a tapAt fallback when synthesis is unsupported), so fusing a jittered tap series does not
150
+ // change the touch mechanism for these inputs.
151
+ if step.kind == "tap", step.synthesized == true {
152
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
153
+ synthesizedTapAt(app: activeApp, x: x, y: y)
154
+ }
155
+ if case .performed = outcome {
156
+ if let pauseMs = step.pauseMs, pauseMs > 0 {
157
+ sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
158
+ }
159
+ return SequenceStepOutcome(
160
+ outcome: outcome,
161
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
162
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
163
+ )
164
+ }
165
+ // Synthesis unsupported (e.g. macOS) — fall through to the drag-based tapAt below.
166
+ }
167
+ let (timing, outcome) = performGesture(activeApp) {
168
+ switch step.kind {
169
+ case "doubleTap":
170
+ // doubleTapAt per step, matching the behavior of the retired tapSeries doubleTap path.
171
+ return doubleTapAt(app: activeApp, x: x, y: y)
172
+ case "longPress":
173
+ let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
174
+ return longPressAt(app: activeApp, x: x, y: y, duration: duration)
175
+ case "drag":
176
+ // Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command
177
+ // (RunnerTests+CommandExecution.swift). durationMs is intentionally ignored on this
178
+ // coordinate-drag path, matching that command's non-synthesized branch.
179
+ let dragPoints = keyboardAvoidingDragPoints(
180
+ app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
181
+ return dragAt(
182
+ app: activeApp,
183
+ x: dragPoints.x,
184
+ y: dragPoints.y,
185
+ x2: dragPoints.x2,
186
+ y2: dragPoints.y2,
187
+ holdDuration: coordinateDragHoldDuration()
188
+ )
189
+ default:
190
+ return tapAt(app: activeApp, x: x, y: y)
191
+ }
192
+ }
193
+ // Sleep AFTER the step — pauseMs is the inter-step gap — but only when the step performed.
194
+ // assembleSequenceExecution stops at the first unsupported outcome, so pausing after a failed
195
+ // step would burn up to 10s of watchdog budget with no following step to separate from.
196
+ if case .performed = outcome, let pauseMs = step.pauseMs, pauseMs > 0 {
197
+ sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
198
+ }
199
+ return SequenceStepOutcome(
200
+ outcome: outcome,
201
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
202
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
203
+ )
204
+ }
205
+
206
+ private func sequenceResponse(
207
+ execution: SequenceExecutionResult,
208
+ touchFrame: TouchVisualizationFrame?
209
+ ) -> Response {
210
+ return Response(
211
+ ok: true,
212
+ data: DataPayload(
213
+ message: "sequence",
214
+ gestureStartUptimeMs: execution.gestureStartUptimeMs,
215
+ gestureEndUptimeMs: execution.gestureEndUptimeMs,
216
+ x: touchFrame?.x,
217
+ y: touchFrame?.y,
218
+ referenceWidth: touchFrame?.referenceWidth,
219
+ referenceHeight: touchFrame?.referenceHeight,
220
+ completedSteps: execution.completedSteps,
221
+ failedStepIndex: execution.failedStepIndex,
222
+ sequenceResults: execution.results
223
+ )
224
+ )
225
+ }
226
+
227
+ private func sequenceInvalidArgs(_ message: String) -> Response {
228
+ Response(ok: false, error: ErrorPayload(code: "INVALID_ARGS", message: message))
229
+ }
230
+ }
231
+
232
+ // MARK: - In-bundle unit tests (device-free)
233
+
234
+ extension RunnerTests {
235
+ func testSequenceDecodesStepsFromWire() throws {
236
+ let json = """
237
+ {"command":"sequence","commandId":"seq-1","steps":[
238
+ {"kind":"tap","x":100,"y":200},
239
+ {"kind":"doubleTap","x":101,"y":200},
240
+ {"kind":"longPress","x":102,"y":200,"durationMs":300},
241
+ {"kind":"drag","x":10,"y":600,"x2":10,"y2":200,"durationMs":250,"pauseMs":50}
242
+ ]}
243
+ """
244
+ let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8))
245
+ XCTAssertEqual(command.command, .sequence)
246
+ XCTAssertEqual(command.steps?.count, 4)
247
+ XCTAssertEqual(command.steps?[0].kind, "tap")
248
+ XCTAssertEqual(command.steps?[1].kind, "doubleTap")
249
+ XCTAssertEqual(command.steps?[3].x2, 10)
250
+ XCTAssertEqual(command.steps?[3].pauseMs, 50)
251
+ }
252
+
253
+ func testSequenceAcceptsDoubleTapKind() {
254
+ // A doubleTap step missing coords must fail on the coords check, not the kind allowlist —
255
+ // proving "doubleTap" passes validateSequenceStep without needing a device to execute on.
256
+ let response = executeSequenceForTest(steps: [
257
+ sequenceStep(kind: "doubleTap", x: nil)
258
+ ])
259
+ XCTAssertEqual(response.ok, false)
260
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
261
+ XCTAssertTrue(response.error?.message.contains("requires finite x and y") ?? false)
262
+ XCTAssertFalse(response.error?.message.contains("unsupported kind") ?? true)
263
+ }
264
+
265
+ func testSequenceRejectsUnknownKind() throws {
266
+ let response = executeSequenceForTest(steps: [
267
+ sequenceStep(kind: "tap", x: 1, y: 2),
268
+ sequenceStep(kind: "pinch", x: 3, y: 4),
269
+ ])
270
+ XCTAssertEqual(response.ok, false)
271
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
272
+ XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
273
+ XCTAssertTrue(response.error?.message.contains("pinch") ?? false)
274
+ }
275
+
276
+ func testSequenceRejectsEmpty() {
277
+ let response = executeSequenceForTest(steps: [])
278
+ XCTAssertEqual(response.ok, false)
279
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
280
+ }
281
+
282
+ func testSequenceRejectsTooManySteps() {
283
+ let steps = (0..<21).map { _ in sequenceStep(kind: "tap", x: 1, y: 2) }
284
+ let response = executeSequenceForTest(steps: steps)
285
+ XCTAssertEqual(response.ok, false)
286
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
287
+ XCTAssertTrue(response.error?.message.contains("at most 20") ?? false)
288
+ }
289
+
290
+ func testSequenceRejectsDragMissingSecondPoint() {
291
+ let response = executeSequenceForTest(steps: [
292
+ sequenceStep(kind: "tap", x: 1, y: 2),
293
+ sequenceStep(kind: "drag", x: 3, y: 4),
294
+ ])
295
+ XCTAssertEqual(response.ok, false)
296
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
297
+ XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
298
+ }
299
+
300
+ func testAssembleSequencePreservesOrderOnSuccess() {
301
+ let steps = [
302
+ sequenceStep(kind: "tap", x: 1, y: 1),
303
+ sequenceStep(kind: "longPress", x: 2, y: 2),
304
+ sequenceStep(kind: "tap", x: 3, y: 3),
305
+ ]
306
+ var calls: [Int] = []
307
+ let execution = assembleSequenceExecution(steps: steps) { index, _ in
308
+ calls.append(index)
309
+ return SequenceStepOutcome(
310
+ outcome: .performed,
311
+ gestureStartUptimeMs: Double(index * 10),
312
+ gestureEndUptimeMs: Double(index * 10 + 5)
313
+ )
314
+ }
315
+ XCTAssertEqual(calls, [0, 1, 2])
316
+ XCTAssertEqual(execution.completedSteps, 3)
317
+ XCTAssertNil(execution.failedStepIndex)
318
+ XCTAssertEqual(execution.results.map { $0.kind }, ["tap", "longPress", "tap"])
319
+ XCTAssertEqual(execution.gestureStartUptimeMs, 0)
320
+ XCTAssertEqual(execution.gestureEndUptimeMs, 25)
321
+ }
322
+
323
+ func testAssembleSequenceStopsAtFirstFailure() {
324
+ let steps = [
325
+ sequenceStep(kind: "tap", x: 1, y: 1),
326
+ sequenceStep(kind: "drag", x: 2, y: 2),
327
+ sequenceStep(kind: "tap", x: 3, y: 3),
328
+ ]
329
+ var calls: [Int] = []
330
+ let execution = assembleSequenceExecution(steps: steps) { index, _ in
331
+ calls.append(index)
332
+ if index == 1 {
333
+ return SequenceStepOutcome(
334
+ outcome: .unsupported(message: "drag unsupported", hint: nil),
335
+ gestureStartUptimeMs: 10,
336
+ gestureEndUptimeMs: 15
337
+ )
338
+ }
339
+ return SequenceStepOutcome(outcome: .performed, gestureStartUptimeMs: 0, gestureEndUptimeMs: 5)
340
+ }
341
+ // Step 2 is never invoked.
342
+ XCTAssertEqual(calls, [0, 1])
343
+ XCTAssertEqual(execution.completedSteps, 1)
344
+ XCTAssertEqual(execution.failedStepIndex, 1)
345
+ // results.count == completedSteps + 1 (the failed step).
346
+ XCTAssertEqual(execution.results.count, 2)
347
+ XCTAssertEqual(execution.results[1].ok, false)
348
+ XCTAssertEqual(execution.results[1].errorCode, "UNSUPPORTED_OPERATION")
349
+ XCTAssertEqual(execution.results[1].errorMessage, "drag unsupported")
350
+ }
351
+
352
+ func testSequenceWorstCaseResponseStaysUnderJournalCap() throws {
353
+ let longMessage = String(repeating: "e", count: 200)
354
+ let results = (0..<20).map { index in
355
+ SequenceStepResult(
356
+ ok: index < 19,
357
+ kind: "drag",
358
+ errorCode: index < 19 ? nil : "UNSUPPORTED_OPERATION",
359
+ errorMessage: index < 19 ? nil : longMessage,
360
+ gestureStartUptimeMs: 123456.789,
361
+ gestureEndUptimeMs: 123466.789
362
+ )
363
+ }
364
+ let response = Response(
365
+ ok: true,
366
+ data: DataPayload(
367
+ message: "sequence",
368
+ completedSteps: 19,
369
+ failedStepIndex: 19,
370
+ sequenceResults: results
371
+ )
372
+ )
373
+ let encoded = try JSONEncoder().encode(response)
374
+ XCTAssertLessThan(encoded.count, 16 * 1024)
375
+ }
376
+
377
+ private func sequenceStep(
378
+ kind: String,
379
+ x: Double?,
380
+ y: Double? = nil,
381
+ x2: Double? = nil,
382
+ y2: Double? = nil
383
+ ) -> SequenceStep {
384
+ SequenceStep(
385
+ kind: kind, x: x, y: y, x2: x2, y2: y2, durationMs: nil, pauseMs: nil, synthesized: nil)
386
+ }
387
+
388
+ /// Validation runs before any executor call, so the INVALID_ARGS paths are exercised without
389
+ /// reaching the device executor (which is never invoked when validation rejects).
390
+ private func executeSequenceForTest(steps: [SequenceStep]) -> Response {
391
+ let command = makeSequenceCommand(steps: steps)
392
+ return executeSequence(command: command, activeApp: app)
393
+ }
394
+
395
+ /// Build a sequence Command via JSON so the test does not depend on the memberwise init's
396
+ /// parameter order.
397
+ private func makeSequenceCommand(steps: [SequenceStep]) -> Command {
398
+ struct SequenceCommandFixture: Encodable {
399
+ let command = "sequence"
400
+ let commandId = "seq-test"
401
+ let steps: [SequenceStep]
402
+ }
403
+ let data = try! JSONEncoder().encode(SequenceCommandFixture(steps: steps))
404
+ return try! JSONDecoder().decode(Command.self, from: data)
405
+ }
406
+ }