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,450 @@
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
+ if step.kind == "drag", step.synthesized == true {
168
+ let dragPoints = keyboardAvoidingDragPoints(
169
+ app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
170
+ let durationMs = min(max(step.durationMs ?? 250, 16), 10000)
171
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
172
+ synthesizedDragAt(
173
+ app: activeApp,
174
+ x: dragPoints.x,
175
+ y: dragPoints.y,
176
+ x2: dragPoints.x2,
177
+ y2: dragPoints.y2,
178
+ durationMs: durationMs
179
+ )
180
+ }
181
+ if case .performed = outcome {
182
+ if let pauseMs = step.pauseMs, pauseMs > 0 {
183
+ sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
184
+ }
185
+ return SequenceStepOutcome(
186
+ outcome: outcome,
187
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
188
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
189
+ )
190
+ }
191
+ let fallbackHoldDuration = synthesizedSwipeFallbackHoldDuration(durationMs: step.durationMs ?? 250)
192
+ let (fallbackTiming, fallbackOutcome) = performGesture(activeApp) {
193
+ dragAt(
194
+ app: activeApp,
195
+ x: dragPoints.x,
196
+ y: dragPoints.y,
197
+ x2: dragPoints.x2,
198
+ y2: dragPoints.y2,
199
+ holdDuration: fallbackHoldDuration
200
+ )
201
+ }
202
+ if case .performed = fallbackOutcome, let pauseMs = step.pauseMs, pauseMs > 0 {
203
+ sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
204
+ }
205
+ return SequenceStepOutcome(
206
+ outcome: fallbackOutcome,
207
+ gestureStartUptimeMs: fallbackTiming.gestureStartUptimeMs,
208
+ gestureEndUptimeMs: fallbackTiming.gestureEndUptimeMs
209
+ )
210
+ }
211
+ let (timing, outcome) = performGesture(activeApp) {
212
+ switch step.kind {
213
+ case "doubleTap":
214
+ // doubleTapAt per step, matching the behavior of the retired tapSeries doubleTap path.
215
+ return doubleTapAt(app: activeApp, x: x, y: y)
216
+ case "longPress":
217
+ let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
218
+ return longPressAt(app: activeApp, x: x, y: y, duration: duration)
219
+ case "drag":
220
+ // Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command.
221
+ // The non-synthesized coordinate-drag path ignores durationMs, matching that command's
222
+ // non-synthesized branch.
223
+ let dragPoints = keyboardAvoidingDragPoints(
224
+ app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
225
+ return dragAt(
226
+ app: activeApp,
227
+ x: dragPoints.x,
228
+ y: dragPoints.y,
229
+ x2: dragPoints.x2,
230
+ y2: dragPoints.y2,
231
+ holdDuration: coordinateDragHoldDuration()
232
+ )
233
+ default:
234
+ return tapAt(app: activeApp, x: x, y: y)
235
+ }
236
+ }
237
+ // Sleep AFTER the step — pauseMs is the inter-step gap — but only when the step performed.
238
+ // assembleSequenceExecution stops at the first unsupported outcome, so pausing after a failed
239
+ // step would burn up to 10s of watchdog budget with no following step to separate from.
240
+ if case .performed = outcome, let pauseMs = step.pauseMs, pauseMs > 0 {
241
+ sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
242
+ }
243
+ return SequenceStepOutcome(
244
+ outcome: outcome,
245
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
246
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
247
+ )
248
+ }
249
+
250
+ private func sequenceResponse(
251
+ execution: SequenceExecutionResult,
252
+ touchFrame: TouchVisualizationFrame?
253
+ ) -> Response {
254
+ return Response(
255
+ ok: true,
256
+ data: DataPayload(
257
+ message: "sequence",
258
+ gestureStartUptimeMs: execution.gestureStartUptimeMs,
259
+ gestureEndUptimeMs: execution.gestureEndUptimeMs,
260
+ x: touchFrame?.x,
261
+ y: touchFrame?.y,
262
+ referenceWidth: touchFrame?.referenceWidth,
263
+ referenceHeight: touchFrame?.referenceHeight,
264
+ completedSteps: execution.completedSteps,
265
+ failedStepIndex: execution.failedStepIndex,
266
+ sequenceResults: execution.results
267
+ )
268
+ )
269
+ }
270
+
271
+ private func sequenceInvalidArgs(_ message: String) -> Response {
272
+ Response(ok: false, error: ErrorPayload(code: "INVALID_ARGS", message: message))
273
+ }
274
+ }
275
+
276
+ // MARK: - In-bundle unit tests (device-free)
277
+
278
+ extension RunnerTests {
279
+ func testSequenceDecodesStepsFromWire() throws {
280
+ let json = """
281
+ {"command":"sequence","commandId":"seq-1","steps":[
282
+ {"kind":"tap","x":100,"y":200},
283
+ {"kind":"doubleTap","x":101,"y":200},
284
+ {"kind":"longPress","x":102,"y":200,"durationMs":300},
285
+ {"kind":"drag","x":10,"y":600,"x2":10,"y2":200,"durationMs":250,"pauseMs":50}
286
+ ]}
287
+ """
288
+ let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8))
289
+ XCTAssertEqual(command.command, .sequence)
290
+ XCTAssertEqual(command.steps?.count, 4)
291
+ XCTAssertEqual(command.steps?[0].kind, "tap")
292
+ XCTAssertEqual(command.steps?[1].kind, "doubleTap")
293
+ XCTAssertEqual(command.steps?[3].x2, 10)
294
+ XCTAssertEqual(command.steps?[3].pauseMs, 50)
295
+ }
296
+
297
+ func testSequenceAcceptsDoubleTapKind() {
298
+ // A doubleTap step missing coords must fail on the coords check, not the kind allowlist —
299
+ // proving "doubleTap" passes validateSequenceStep without needing a device to execute on.
300
+ let response = executeSequenceForTest(steps: [
301
+ sequenceStep(kind: "doubleTap", x: nil)
302
+ ])
303
+ XCTAssertEqual(response.ok, false)
304
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
305
+ XCTAssertTrue(response.error?.message.contains("requires finite x and y") ?? false)
306
+ XCTAssertFalse(response.error?.message.contains("unsupported kind") ?? true)
307
+ }
308
+
309
+ func testSequenceRejectsUnknownKind() throws {
310
+ let response = executeSequenceForTest(steps: [
311
+ sequenceStep(kind: "tap", x: 1, y: 2),
312
+ sequenceStep(kind: "pinch", x: 3, y: 4),
313
+ ])
314
+ XCTAssertEqual(response.ok, false)
315
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
316
+ XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
317
+ XCTAssertTrue(response.error?.message.contains("pinch") ?? false)
318
+ }
319
+
320
+ func testSequenceRejectsEmpty() {
321
+ let response = executeSequenceForTest(steps: [])
322
+ XCTAssertEqual(response.ok, false)
323
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
324
+ }
325
+
326
+ func testSequenceRejectsTooManySteps() {
327
+ let steps = (0..<21).map { _ in sequenceStep(kind: "tap", x: 1, y: 2) }
328
+ let response = executeSequenceForTest(steps: steps)
329
+ XCTAssertEqual(response.ok, false)
330
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
331
+ XCTAssertTrue(response.error?.message.contains("at most 20") ?? false)
332
+ }
333
+
334
+ func testSequenceRejectsDragMissingSecondPoint() {
335
+ let response = executeSequenceForTest(steps: [
336
+ sequenceStep(kind: "tap", x: 1, y: 2),
337
+ sequenceStep(kind: "drag", x: 3, y: 4),
338
+ ])
339
+ XCTAssertEqual(response.ok, false)
340
+ XCTAssertEqual(response.error?.code, "INVALID_ARGS")
341
+ XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
342
+ }
343
+
344
+ func testAssembleSequencePreservesOrderOnSuccess() {
345
+ let steps = [
346
+ sequenceStep(kind: "tap", x: 1, y: 1),
347
+ sequenceStep(kind: "longPress", x: 2, y: 2),
348
+ sequenceStep(kind: "tap", x: 3, y: 3),
349
+ ]
350
+ var calls: [Int] = []
351
+ let execution = assembleSequenceExecution(steps: steps) { index, _ in
352
+ calls.append(index)
353
+ return SequenceStepOutcome(
354
+ outcome: .performed,
355
+ gestureStartUptimeMs: Double(index * 10),
356
+ gestureEndUptimeMs: Double(index * 10 + 5)
357
+ )
358
+ }
359
+ XCTAssertEqual(calls, [0, 1, 2])
360
+ XCTAssertEqual(execution.completedSteps, 3)
361
+ XCTAssertNil(execution.failedStepIndex)
362
+ XCTAssertEqual(execution.results.map { $0.kind }, ["tap", "longPress", "tap"])
363
+ XCTAssertEqual(execution.gestureStartUptimeMs, 0)
364
+ XCTAssertEqual(execution.gestureEndUptimeMs, 25)
365
+ }
366
+
367
+ func testAssembleSequenceStopsAtFirstFailure() {
368
+ let steps = [
369
+ sequenceStep(kind: "tap", x: 1, y: 1),
370
+ sequenceStep(kind: "drag", x: 2, y: 2),
371
+ sequenceStep(kind: "tap", x: 3, y: 3),
372
+ ]
373
+ var calls: [Int] = []
374
+ let execution = assembleSequenceExecution(steps: steps) { index, _ in
375
+ calls.append(index)
376
+ if index == 1 {
377
+ return SequenceStepOutcome(
378
+ outcome: .unsupported(message: "drag unsupported", hint: nil),
379
+ gestureStartUptimeMs: 10,
380
+ gestureEndUptimeMs: 15
381
+ )
382
+ }
383
+ return SequenceStepOutcome(outcome: .performed, gestureStartUptimeMs: 0, gestureEndUptimeMs: 5)
384
+ }
385
+ // Step 2 is never invoked.
386
+ XCTAssertEqual(calls, [0, 1])
387
+ XCTAssertEqual(execution.completedSteps, 1)
388
+ XCTAssertEqual(execution.failedStepIndex, 1)
389
+ // results.count == completedSteps + 1 (the failed step).
390
+ XCTAssertEqual(execution.results.count, 2)
391
+ XCTAssertEqual(execution.results[1].ok, false)
392
+ XCTAssertEqual(execution.results[1].errorCode, "UNSUPPORTED_OPERATION")
393
+ XCTAssertEqual(execution.results[1].errorMessage, "drag unsupported")
394
+ }
395
+
396
+ func testSequenceWorstCaseResponseStaysUnderJournalCap() throws {
397
+ let longMessage = String(repeating: "e", count: 200)
398
+ let results = (0..<20).map { index in
399
+ SequenceStepResult(
400
+ ok: index < 19,
401
+ kind: "drag",
402
+ errorCode: index < 19 ? nil : "UNSUPPORTED_OPERATION",
403
+ errorMessage: index < 19 ? nil : longMessage,
404
+ gestureStartUptimeMs: 123456.789,
405
+ gestureEndUptimeMs: 123466.789
406
+ )
407
+ }
408
+ let response = Response(
409
+ ok: true,
410
+ data: DataPayload(
411
+ message: "sequence",
412
+ completedSteps: 19,
413
+ failedStepIndex: 19,
414
+ sequenceResults: results
415
+ )
416
+ )
417
+ let encoded = try JSONEncoder().encode(response)
418
+ XCTAssertLessThan(encoded.count, 16 * 1024)
419
+ }
420
+
421
+ private func sequenceStep(
422
+ kind: String,
423
+ x: Double?,
424
+ y: Double? = nil,
425
+ x2: Double? = nil,
426
+ y2: Double? = nil
427
+ ) -> SequenceStep {
428
+ SequenceStep(
429
+ kind: kind, x: x, y: y, x2: x2, y2: y2, durationMs: nil, pauseMs: nil, synthesized: nil)
430
+ }
431
+
432
+ /// Validation runs before any executor call, so the INVALID_ARGS paths are exercised without
433
+ /// reaching the device executor (which is never invoked when validation rejects).
434
+ private func executeSequenceForTest(steps: [SequenceStep]) -> Response {
435
+ let command = makeSequenceCommand(steps: steps)
436
+ return executeSequence(command: command, activeApp: app)
437
+ }
438
+
439
+ /// Build a sequence Command via JSON so the test does not depend on the memberwise init's
440
+ /// parameter order.
441
+ private func makeSequenceCommand(steps: [SequenceStep]) -> Command {
442
+ struct SequenceCommandFixture: Encodable {
443
+ let command = "sequence"
444
+ let commandId = "seq-test"
445
+ let steps: [SequenceStep]
446
+ }
447
+ let data = try! JSONEncoder().encode(SequenceCommandFixture(steps: steps))
448
+ return try! JSONDecoder().decode(Command.self, from: data)
449
+ }
450
+ }