agent-device 0.16.9 → 0.16.11

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 (54) hide show
  1. package/README.md +1 -0
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.9.apk → agent-device-android-multitouch-helper-0.16.11.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.11.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.9.manifest.json → agent-device-android-multitouch-helper-0.16.11.manifest.json} +4 -4
  5. package/android-snapshot-helper/README.md +6 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.11.apk +0 -0
  7. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.11.apk.sha256 +1 -0
  8. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.9.manifest.json → agent-device-android-snapshot-helper-0.16.11.manifest.json} +6 -6
  9. package/dist/src/1352.js +1 -1
  10. package/dist/src/221.js +6 -6
  11. package/dist/src/2415.js +27 -27
  12. package/dist/src/2805.js +1 -1
  13. package/dist/src/4778.js +1 -0
  14. package/dist/src/5792.js +1 -1
  15. package/dist/src/6085.js +1 -1
  16. package/dist/src/6232.js +1 -1
  17. package/dist/src/8699.js +1 -1
  18. package/dist/src/9238.js +4 -0
  19. package/dist/src/9533.js +1 -1
  20. package/dist/src/9542.js +3 -3
  21. package/dist/src/apple.js +1 -1
  22. package/dist/src/apps.js +2 -2
  23. package/dist/src/args.js +54 -25
  24. package/dist/src/batch.d.ts +1 -0
  25. package/dist/src/cli.js +19 -19
  26. package/dist/src/command-metadata.js +1 -1
  27. package/dist/src/command-surface.js +1 -1
  28. package/dist/src/contracts.d.ts +1 -0
  29. package/dist/src/contracts.js +1 -1
  30. package/dist/src/generic.js +10 -10
  31. package/dist/src/index.d.ts +9 -0
  32. package/dist/src/input-actions.js +1 -1
  33. package/dist/src/record-trace.js +3 -3
  34. package/dist/src/session.js +9 -9
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +7 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +109 -0
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +282 -226
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +282 -0
  39. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +29 -0
  40. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +44 -34
  41. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +41 -1
  42. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -20
  43. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +10 -50
  44. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +3 -23
  45. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +64 -22
  46. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +7 -4
  47. package/package.json +1 -1
  48. package/server.json +2 -2
  49. package/skills/dogfood/SKILL.md +1 -1
  50. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.9.apk.sha256 +0 -1
  51. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.9.apk +0 -0
  52. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.9.apk.sha256 +0 -1
  53. package/dist/src/2842.js +0 -1
  54. package/dist/src/8114.js +0 -4
@@ -13,6 +13,10 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
+ private func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
17
+ min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
18
+ }
19
+
16
20
  func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
17
21
  switch outcome {
18
22
  case .performed:
@@ -25,7 +29,163 @@ extension RunnerTests {
25
29
  }
26
30
  }
27
31
 
32
+ /// Optional visualization frame returned with a gesture response.
33
+ enum GestureFrame {
34
+ case none
35
+ case touch(TouchVisualizationFrame?)
36
+ case drag(DragVisualizationFrame)
37
+ }
38
+
39
+ struct GestureFallback {
40
+ let strategy: String
41
+ let message: String
42
+ let hint: String?
43
+ }
44
+
45
+ private func gestureFallback(strategy: String, from outcome: RunnerInteractionOutcome) -> GestureFallback? {
46
+ switch outcome {
47
+ case .performed:
48
+ return nil
49
+ case .unsupported(let message, let hint):
50
+ return GestureFallback(strategy: strategy, message: message, hint: hint)
51
+ }
52
+ }
53
+
54
+ private func performDragSeries(
55
+ count: Int,
56
+ pauseMs: Double,
57
+ pattern: String,
58
+ points: DragPoints,
59
+ _ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome
60
+ ) -> RunnerInteractionOutcome {
61
+ var outcome = RunnerInteractionOutcome.performed
62
+ runSeries(count: count, pauseMs: pauseMs) { idx in
63
+ guard case .performed = outcome else {
64
+ return
65
+ }
66
+ let reverse = pattern == "ping-pong" && (idx % 2 == 1)
67
+ let startX = reverse ? points.x2 : points.x
68
+ let startY = reverse ? points.y2 : points.y
69
+ let endX = reverse ? points.x : points.x2
70
+ let endY = reverse ? points.y : points.y2
71
+ outcome = drag(startX, startY, endX, endY)
72
+ }
73
+ return outcome
74
+ }
75
+
76
+ /// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
77
+ /// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
78
+ /// gestures (pinch/rotate/transform) pass `false` because RunnerSynthesizedGesture governs its
79
+ /// own timing. Returns the captured timing and the action's outcome.
80
+ ///
81
+ /// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
82
+ /// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior.
83
+ private func performGesture(
84
+ _ app: XCUIApplication,
85
+ idleTimeout: Bool = true,
86
+ _ action: () -> RunnerInteractionOutcome
87
+ ) -> (timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double), outcome: RunnerInteractionOutcome) {
88
+ var outcome = RunnerInteractionOutcome.performed
89
+ let timing = measureGesture {
90
+ if idleTimeout {
91
+ withTemporaryScrollIdleTimeoutIfSupported(app) { outcome = action() }
92
+ } else {
93
+ outcome = action()
94
+ }
95
+ }
96
+ return (timing, outcome)
97
+ }
98
+
99
+ /// Single factory for the success payload every gesture returns (message + gesture timing +
100
+ /// an optional touch/drag visualization frame), so the field shape lives in one place.
101
+ private func gestureResponse(
102
+ message: String,
103
+ timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
104
+ frame: GestureFrame = .none,
105
+ fallback: GestureFallback? = nil
106
+ ) -> Response {
107
+ let data: DataPayload
108
+ switch frame {
109
+ case .none:
110
+ data = DataPayload(
111
+ message: message,
112
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
113
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
114
+ gestureFallback: fallback?.strategy,
115
+ gestureFallbackMessage: fallback?.message,
116
+ gestureFallbackHint: fallback?.hint
117
+ )
118
+ case .touch(let f):
119
+ data = DataPayload(
120
+ message: message,
121
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
122
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
123
+ x: f?.x,
124
+ y: f?.y,
125
+ referenceWidth: f?.referenceWidth,
126
+ referenceHeight: f?.referenceHeight,
127
+ gestureFallback: fallback?.strategy,
128
+ gestureFallbackMessage: fallback?.message,
129
+ gestureFallbackHint: fallback?.hint
130
+ )
131
+ case .drag(let f):
132
+ data = DataPayload(
133
+ message: message,
134
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
135
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
136
+ x: f.x,
137
+ y: f.y,
138
+ x2: f.x2,
139
+ y2: f.y2,
140
+ referenceWidth: f.referenceWidth,
141
+ referenceHeight: f.referenceHeight,
142
+ gestureFallback: fallback?.strategy,
143
+ gestureFallbackMessage: fallback?.message,
144
+ gestureFallbackHint: fallback?.hint
145
+ )
146
+ }
147
+ return Response(ok: true, data: data)
148
+ }
149
+
28
150
  func execute(command: Command) throws -> Response {
151
+ if command.command == .status {
152
+ return executeStatus(command: command)
153
+ }
154
+ commandJournal.accept(command: command)
155
+ return try executeAccepted(command: command)
156
+ }
157
+
158
+ func executeAccepted(command: Command) throws -> Response {
159
+ commandJournal.start(command: command)
160
+ do {
161
+ let response = try executeDispatched(command: command)
162
+ commandJournal.finish(command: command, response: response)
163
+ return response
164
+ } catch {
165
+ commandJournal.fail(command: command, error: error)
166
+ throw error
167
+ }
168
+ }
169
+
170
+ func executeStatus(command: Command) -> Response {
171
+ guard
172
+ let statusCommandId = command.statusCommandId?
173
+ .trimmingCharacters(in: .whitespacesAndNewlines),
174
+ !statusCommandId.isEmpty
175
+ else {
176
+ return Response(
177
+ ok: false,
178
+ error: ErrorPayload(
179
+ code: "INVALID_ARGS",
180
+ message: "status requires statusCommandId",
181
+ hint: "Set statusCommandId to the commandId of the runner command to inspect."
182
+ )
183
+ )
184
+ }
185
+ return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
186
+ }
187
+
188
+ private func executeDispatched(command: Command) throws -> Response {
29
189
  if Thread.isMainThread {
30
190
  return try executeOnMainSafely(command: command)
31
191
  }
@@ -183,6 +343,8 @@ extension RunnerTests {
183
343
  }
184
344
 
185
345
  switch command.command {
346
+ case .status:
347
+ return executeStatus(command: command)
186
348
  case .shutdown:
187
349
  stopRecordingIfNeeded()
188
350
  return Response(ok: true, data: DataPayload(message: "shutdown"))
@@ -266,83 +428,46 @@ extension RunnerTests {
266
428
  let touchFrame = frame.isEmpty
267
429
  ? nil
268
430
  : resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
269
- var outcome = RunnerInteractionOutcome.performed
270
- let timing = measureGesture {
271
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
272
- if match.usedNonHittableFallback {
273
- // Maestro compatibility: RN E2E backdoor controls can be 1x1 and
274
- // reported non-hittable by XCTest, while Maestro still taps their
275
- // resolved bounds. Keep this behind the explicit replay-only flag.
276
- outcome = tapAt(app: activeApp, x: frame.midX, y: frame.midY)
277
- } else {
278
- outcome = activateElement(app: activeApp, element: element, action: "tap by selector")
279
- }
431
+ let (timing, outcome) = performGesture(activeApp) {
432
+ if match.usedNonHittableFallback {
433
+ // Maestro compatibility: RN E2E backdoor controls can be 1x1 and
434
+ // reported non-hittable by XCTest, while Maestro still taps their
435
+ // resolved bounds. Keep this behind the explicit replay-only flag.
436
+ return tapAt(app: activeApp, x: frame.midX, y: frame.midY)
280
437
  }
438
+ return activateElement(app: activeApp, element: element, action: "tap by selector")
281
439
  }
282
440
  if let response = unsupportedResponse(for: outcome) {
283
441
  return response
284
442
  }
285
443
  waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
286
- return Response(
287
- ok: true,
288
- data: DataPayload(
289
- message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
290
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
291
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
292
- x: touchFrame?.x,
293
- y: touchFrame?.y,
294
- referenceWidth: touchFrame?.referenceWidth,
295
- referenceHeight: touchFrame?.referenceHeight
296
- )
444
+ return gestureResponse(
445
+ message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
446
+ timing: timing,
447
+ frame: .touch(touchFrame)
297
448
  )
298
449
  }
299
450
  return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
300
451
  }
301
452
  if let text = command.text {
302
453
  if let element = findElement(app: activeApp, text: text) {
303
- var outcome = RunnerInteractionOutcome.performed
304
- let timing = measureGesture {
305
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
306
- outcome = activateElement(app: activeApp, element: element, action: "tap by text")
307
- }
454
+ let (timing, outcome) = performGesture(activeApp) {
455
+ activateElement(app: activeApp, element: element, action: "tap by text")
308
456
  }
309
457
  if let response = unsupportedResponse(for: outcome) {
310
458
  return response
311
459
  }
312
- return Response(
313
- ok: true,
314
- data: DataPayload(
315
- message: "tapped",
316
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
317
- gestureEndUptimeMs: timing.gestureEndUptimeMs
318
- )
319
- )
460
+ return gestureResponse(message: "tapped", timing: timing)
320
461
  }
321
462
  return Response(ok: false, error: ErrorPayload(message: "element not found"))
322
463
  }
323
464
  if let x = command.x, let y = command.y {
324
465
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
325
- var outcome = RunnerInteractionOutcome.performed
326
- let timing = measureGesture {
327
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
328
- outcome = tapAt(app: activeApp, x: x, y: y)
329
- }
330
- }
466
+ let (timing, outcome) = performGesture(activeApp) { tapAt(app: activeApp, x: x, y: y) }
331
467
  if let response = unsupportedResponse(for: outcome) {
332
468
  return response
333
469
  }
334
- return Response(
335
- ok: true,
336
- data: DataPayload(
337
- message: "tapped",
338
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
339
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
340
- x: touchFrame.x,
341
- y: touchFrame.y,
342
- referenceWidth: touchFrame.referenceWidth,
343
- referenceHeight: touchFrame.referenceHeight
344
- )
345
- )
470
+ return gestureResponse(message: "tapped", timing: timing, frame: .touch(touchFrame))
346
471
  }
347
472
  return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
348
473
  case .mouseClick:
@@ -351,6 +476,8 @@ extension RunnerTests {
351
476
  }
352
477
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
353
478
  do {
479
+ // mouseClick throws (it has no RunnerInteractionOutcome), so it keeps raw measureGesture
480
+ // and only routes the success payload through gestureResponse.
354
481
  var clickError: Error?
355
482
  let timing = measureGesture {
356
483
  do {
@@ -362,18 +489,7 @@ extension RunnerTests {
362
489
  if let clickError {
363
490
  throw clickError
364
491
  }
365
- return Response(
366
- ok: true,
367
- data: DataPayload(
368
- message: "clicked",
369
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
370
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
371
- x: touchFrame.x,
372
- y: touchFrame.y,
373
- referenceWidth: touchFrame.referenceWidth,
374
- referenceHeight: touchFrame.referenceHeight
375
- )
376
- )
492
+ return gestureResponse(message: "clicked", timing: timing, frame: .touch(touchFrame))
377
493
  } catch {
378
494
  return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
379
495
  }
@@ -386,89 +502,50 @@ extension RunnerTests {
386
502
  let doubleTap = command.doubleTap ?? false
387
503
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
388
504
  if doubleTap {
389
- var outcome = RunnerInteractionOutcome.performed
390
- let timing = measureGesture {
391
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
392
- runSeries(count: count, pauseMs: intervalMs) { _ in
393
- if case .performed = outcome {
394
- outcome = doubleTapAt(app: activeApp, x: x, y: y)
395
- }
505
+ let (timing, outcome) = performGesture(activeApp) {
506
+ var outcome = RunnerInteractionOutcome.performed
507
+ runSeries(count: count, pauseMs: intervalMs) { _ in
508
+ if case .performed = outcome {
509
+ outcome = doubleTapAt(app: activeApp, x: x, y: y)
396
510
  }
397
511
  }
512
+ return outcome
398
513
  }
399
514
  if let response = unsupportedResponse(for: outcome) {
400
515
  return response
401
516
  }
402
- return Response(
403
- ok: true,
404
- data: DataPayload(
405
- message: "tap series",
406
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
407
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
408
- x: touchFrame.x,
409
- y: touchFrame.y,
410
- referenceWidth: touchFrame.referenceWidth,
411
- referenceHeight: touchFrame.referenceHeight
412
- )
413
- )
517
+ return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
414
518
  }
415
- var outcome = RunnerInteractionOutcome.performed
416
- let timing = measureGesture {
417
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
418
- runSeries(count: count, pauseMs: intervalMs) { _ in
419
- if case .performed = outcome {
420
- outcome = tapAt(app: activeApp, x: x, y: y)
421
- }
519
+ let (timing, outcome) = performGesture(activeApp) {
520
+ var outcome = RunnerInteractionOutcome.performed
521
+ runSeries(count: count, pauseMs: intervalMs) { _ in
522
+ if case .performed = outcome {
523
+ outcome = tapAt(app: activeApp, x: x, y: y)
422
524
  }
423
525
  }
526
+ return outcome
424
527
  }
425
528
  if let response = unsupportedResponse(for: outcome) {
426
529
  return response
427
530
  }
428
- return Response(
429
- ok: true,
430
- data: DataPayload(
431
- message: "tap series",
432
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
433
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
434
- x: touchFrame.x,
435
- y: touchFrame.y,
436
- referenceWidth: touchFrame.referenceWidth,
437
- referenceHeight: touchFrame.referenceHeight
438
- )
439
- )
531
+ return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
440
532
  case .longPress:
441
533
  guard let x = command.x, let y = command.y else {
442
534
  return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
443
535
  }
444
536
  let duration = (command.durationMs ?? 800) / 1000.0
445
537
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
446
- var outcome = RunnerInteractionOutcome.performed
447
- let timing = measureGesture {
448
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
449
- outcome = longPressAt(app: activeApp, x: x, y: y, duration: duration)
450
- }
538
+ let (timing, outcome) = performGesture(activeApp) {
539
+ longPressAt(app: activeApp, x: x, y: y, duration: duration)
451
540
  }
452
541
  if let response = unsupportedResponse(for: outcome) {
453
542
  return response
454
543
  }
455
- return Response(
456
- ok: true,
457
- data: DataPayload(
458
- message: "long pressed",
459
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
460
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
461
- x: touchFrame.x,
462
- y: touchFrame.y,
463
- referenceWidth: touchFrame.referenceWidth,
464
- referenceHeight: touchFrame.referenceHeight
465
- )
466
- )
544
+ return gestureResponse(message: "long pressed", timing: timing, frame: .touch(touchFrame))
467
545
  case .drag:
468
546
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
469
547
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
470
548
  }
471
- let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
472
549
  let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
473
550
  let dragFrame = resolvedDragVisualizationFrame(
474
551
  app: activeApp,
@@ -477,35 +554,45 @@ extension RunnerTests {
477
554
  x2: dragPoints.x2,
478
555
  y2: dragPoints.y2
479
556
  )
480
- var outcome = RunnerInteractionOutcome.performed
481
- let timing = measureGesture {
482
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
483
- outcome = dragAt(
557
+ var fallback: GestureFallback?
558
+ if command.synthesized == true {
559
+ let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
560
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
561
+ synthesizedDragAt(
484
562
  app: activeApp,
485
563
  x: dragPoints.x,
486
564
  y: dragPoints.y,
487
565
  x2: dragPoints.x2,
488
566
  y2: dragPoints.y2,
489
- holdDuration: holdDuration
567
+ durationMs: durationMs
490
568
  )
491
569
  }
570
+ if case .performed = outcome {
571
+ return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
572
+ }
573
+ fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
574
+ }
575
+ let holdDuration = command.synthesized == true
576
+ ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
577
+ : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
578
+ let (timing, outcome) = performGesture(activeApp) {
579
+ dragAt(
580
+ app: activeApp,
581
+ x: dragPoints.x,
582
+ y: dragPoints.y,
583
+ x2: dragPoints.x2,
584
+ y2: dragPoints.y2,
585
+ holdDuration: holdDuration
586
+ )
492
587
  }
493
588
  if let response = unsupportedResponse(for: outcome) {
494
589
  return response
495
590
  }
496
- return Response(
497
- ok: true,
498
- data: DataPayload(
499
- message: "dragged",
500
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
501
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
502
- x: dragFrame.x,
503
- y: dragFrame.y,
504
- x2: dragFrame.x2,
505
- y2: dragFrame.y2,
506
- referenceWidth: dragFrame.referenceWidth,
507
- referenceHeight: dragFrame.referenceHeight
508
- )
591
+ return gestureResponse(
592
+ message: "dragged",
593
+ timing: timing,
594
+ frame: .drag(dragFrame),
595
+ fallback: fallback
509
596
  )
510
597
  case .dragSeries:
511
598
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
@@ -517,49 +604,56 @@ extension RunnerTests {
517
604
  if pattern != "one-way" && pattern != "ping-pong" {
518
605
  return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
519
606
  }
520
- let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
521
607
  let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
522
- var outcome = RunnerInteractionOutcome.performed
523
- let timing = measureGesture {
524
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
525
- runSeries(count: count, pauseMs: pauseMs) { idx in
526
- guard case .performed = outcome else {
527
- return
528
- }
529
- let reverse = pattern == "ping-pong" && (idx % 2 == 1)
530
- if reverse {
531
- outcome = dragAt(
532
- app: activeApp,
533
- x: dragPoints.x2,
534
- y: dragPoints.y2,
535
- x2: dragPoints.x,
536
- y2: dragPoints.y,
537
- holdDuration: holdDuration
538
- )
539
- } else {
540
- outcome = dragAt(
541
- app: activeApp,
542
- x: dragPoints.x,
543
- y: dragPoints.y,
544
- x2: dragPoints.x2,
545
- y2: dragPoints.y2,
546
- holdDuration: holdDuration
547
- )
548
- }
608
+ var fallback: GestureFallback?
609
+ if command.synthesized == true {
610
+ let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
611
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
612
+ performDragSeries(
613
+ count: count,
614
+ pauseMs: pauseMs,
615
+ pattern: pattern,
616
+ points: dragPoints
617
+ ) { startX, startY, endX, endY in
618
+ synthesizedDragAt(
619
+ app: activeApp,
620
+ x: startX,
621
+ y: startY,
622
+ x2: endX,
623
+ y2: endY,
624
+ durationMs: durationMs
625
+ )
549
626
  }
550
627
  }
628
+ if case .performed = outcome {
629
+ return gestureResponse(message: "drag series", timing: timing)
630
+ }
631
+ fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
632
+ }
633
+ let holdDuration = command.synthesized == true
634
+ ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
635
+ : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
636
+ let (timing, outcome) = performGesture(activeApp) {
637
+ performDragSeries(
638
+ count: count,
639
+ pauseMs: pauseMs,
640
+ pattern: pattern,
641
+ points: dragPoints
642
+ ) { startX, startY, endX, endY in
643
+ dragAt(
644
+ app: activeApp,
645
+ x: startX,
646
+ y: startY,
647
+ x2: endX,
648
+ y2: endY,
649
+ holdDuration: holdDuration
650
+ )
651
+ }
551
652
  }
552
653
  if let response = unsupportedResponse(for: outcome) {
553
654
  return response
554
655
  }
555
- return Response(
556
- ok: true,
557
- data: DataPayload(
558
- message: "drag series",
559
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
560
- gestureEndUptimeMs: timing.gestureEndUptimeMs
561
- )
562
- )
656
+ return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
563
657
  case .remotePress:
564
658
  guard let button = tvRemoteButton(from: command.remoteButton) else {
565
659
  return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
@@ -593,32 +687,18 @@ extension RunnerTests {
593
687
  guard let direction = command.direction else {
594
688
  return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
595
689
  }
690
+ // swipe returns an optional frame (tvOS-only) rather than a RunnerInteractionOutcome, so it
691
+ // keeps raw measureGesture and only routes the success payload through gestureResponse.
596
692
  var executedFrame: DragVisualizationFrame?
597
693
  let timing = measureGesture {
598
694
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
599
- executedFrame = swipe(
600
- app: activeApp,
601
- direction: direction
602
- )
695
+ executedFrame = swipe(app: activeApp, direction: direction)
603
696
  }
604
697
  }
605
698
  guard let dragFrame = executedFrame else {
606
699
  return Response(ok: false, error: ErrorPayload(message: "swipe is only supported on tvOS"))
607
700
  }
608
- return Response(
609
- ok: true,
610
- data: DataPayload(
611
- message: "swiped",
612
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
613
- gestureEndUptimeMs: timing.gestureEndUptimeMs,
614
- x: dragFrame.x,
615
- y: dragFrame.y,
616
- x2: dragFrame.x2,
617
- y2: dragFrame.y2,
618
- referenceWidth: dragFrame.referenceWidth,
619
- referenceHeight: dragFrame.referenceHeight
620
- )
621
- )
701
+ return gestureResponse(message: "swiped", timing: timing, frame: .drag(dragFrame))
622
702
  case .findText:
623
703
  guard let text = command.text else {
624
704
  return Response(ok: false, error: ErrorPayload(message: "findText requires text"))
@@ -785,21 +865,13 @@ extension RunnerTests {
785
865
  guard let scale = command.scale, scale > 0 else {
786
866
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
787
867
  }
788
- var outcome = RunnerInteractionOutcome.performed
789
- let timing = measureGesture {
790
- outcome = pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
868
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
869
+ pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
791
870
  }
792
871
  if let response = unsupportedResponse(for: outcome) {
793
872
  return response
794
873
  }
795
- return Response(
796
- ok: true,
797
- data: DataPayload(
798
- message: "pinched",
799
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
800
- gestureEndUptimeMs: timing.gestureEndUptimeMs
801
- )
802
- )
874
+ return gestureResponse(message: "pinched", timing: timing)
803
875
  case .rotateGesture:
804
876
  guard let degrees = command.degrees, degrees.isFinite else {
805
877
  return Response(ok: false, error: ErrorPayload(message: "rotateGesture requires degrees"))
@@ -808,9 +880,8 @@ extension RunnerTests {
808
880
  guard velocity.isFinite && velocity != 0 else {
809
881
  return Response(ok: false, error: ErrorPayload(message: "rotateGesture velocity must be non-zero"))
810
882
  }
811
- var outcome = RunnerInteractionOutcome.performed
812
- let timing = measureGesture {
813
- outcome = rotateGesture(
883
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
884
+ rotateGesture(
814
885
  app: activeApp,
815
886
  degrees: degrees,
816
887
  x: command.x,
@@ -821,14 +892,7 @@ extension RunnerTests {
821
892
  if let response = unsupportedResponse(for: outcome) {
822
893
  return response
823
894
  }
824
- return Response(
825
- ok: true,
826
- data: DataPayload(
827
- message: "rotatedGesture",
828
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
829
- gestureEndUptimeMs: timing.gestureEndUptimeMs
830
- )
831
- )
895
+ return gestureResponse(message: "rotatedGesture", timing: timing)
832
896
  case .transformGesture:
833
897
  guard
834
898
  let x = command.x,
@@ -852,9 +916,8 @@ extension RunnerTests {
852
916
  guard durationMs.isFinite && durationMs >= 16 else {
853
917
  return Response(ok: false, error: ErrorPayload(message: "transformGesture durationMs must be >= 16"))
854
918
  }
855
- var outcome = RunnerInteractionOutcome.performed
856
- let timing = measureGesture {
857
- outcome = transformGesture(
919
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
920
+ transformGesture(
858
921
  app: activeApp,
859
922
  x: x,
860
923
  y: y,
@@ -868,14 +931,7 @@ extension RunnerTests {
868
931
  if let response = unsupportedResponse(for: outcome) {
869
932
  return response
870
933
  }
871
- return Response(
872
- ok: true,
873
- data: DataPayload(
874
- message: "transformedGesture",
875
- gestureStartUptimeMs: timing.gestureStartUptimeMs,
876
- gestureEndUptimeMs: timing.gestureEndUptimeMs
877
- )
878
- )
934
+ return gestureResponse(message: "transformedGesture", timing: timing)
879
935
  }
880
936
  }
881
937