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
@@ -13,11 +13,11 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
- private func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
16
+ func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
17
17
  min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
18
18
  }
19
19
 
20
- private func coordinateDragHoldDuration() -> TimeInterval {
20
+ func coordinateDragHoldDuration() -> TimeInterval {
21
21
  0.050
22
22
  }
23
23
 
@@ -55,27 +55,6 @@ extension RunnerTests {
55
55
  }
56
56
  }
57
57
 
58
- private func performDragSeries(
59
- count: Int,
60
- pauseMs: Double,
61
- pattern: String,
62
- points: DragPoints,
63
- _ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome
64
- ) -> RunnerInteractionOutcome {
65
- var outcome = RunnerInteractionOutcome.performed
66
- runSeries(count: count, pauseMs: pauseMs) { idx in
67
- guard case .performed = outcome else {
68
- return
69
- }
70
- let reverse = pattern == "ping-pong" && (idx % 2 == 1)
71
- let startX = reverse ? points.x2 : points.x
72
- let startY = reverse ? points.y2 : points.y
73
- let endX = reverse ? points.x : points.x2
74
- let endY = reverse ? points.y : points.y2
75
- outcome = drag(startX, startY, endX, endY)
76
- }
77
- return outcome
78
- }
79
58
 
80
59
  /// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
81
60
  /// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
@@ -84,7 +63,7 @@ extension RunnerTests {
84
63
  ///
85
64
  /// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
86
65
  /// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior.
87
- private func performGesture(
66
+ func performGesture(
88
67
  _ app: XCUIApplication,
89
68
  idleTimeout: Bool = true,
90
69
  _ action: () -> RunnerInteractionOutcome
@@ -244,6 +223,8 @@ extension RunnerTests {
244
223
  }
245
224
 
246
225
  func executeUptime() -> Response {
226
+ // Placeholder value: the transport layer (jsonResponse) overwrites currentUptimeMs with a
227
+ // fresher send-time stamp on every ok response; kept so direct callers still get a value.
247
228
  Response(
248
229
  ok: true,
249
230
  data: DataPayload(currentUptimeMs: currentUptimeMs())
@@ -580,42 +561,6 @@ extension RunnerTests {
580
561
  } catch {
581
562
  return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
582
563
  }
583
- case .tapSeries:
584
- guard let x = command.x, let y = command.y else {
585
- return Response(ok: false, error: ErrorPayload(message: "tapSeries requires x and y"))
586
- }
587
- let count = max(Int(command.count ?? 1), 1)
588
- let intervalMs = max(command.intervalMs ?? 0, 0)
589
- let doubleTap = command.doubleTap ?? false
590
- let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
591
- if doubleTap {
592
- let (timing, outcome) = performGesture(activeApp) {
593
- var outcome = RunnerInteractionOutcome.performed
594
- runSeries(count: count, pauseMs: intervalMs) { _ in
595
- if case .performed = outcome {
596
- outcome = doubleTapAt(app: activeApp, x: x, y: y)
597
- }
598
- }
599
- return outcome
600
- }
601
- if let response = unsupportedResponse(for: outcome) {
602
- return response
603
- }
604
- return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
605
- }
606
- let (timing, outcome) = performGesture(activeApp) {
607
- var outcome = RunnerInteractionOutcome.performed
608
- runSeries(count: count, pauseMs: intervalMs) { _ in
609
- if case .performed = outcome {
610
- outcome = tapAt(app: activeApp, x: x, y: y)
611
- }
612
- }
613
- return outcome
614
- }
615
- if let response = unsupportedResponse(for: outcome) {
616
- return response
617
- }
618
- return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
619
564
  case .longPress:
620
565
  guard let x = command.x, let y = command.y else {
621
566
  return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
@@ -633,114 +578,63 @@ extension RunnerTests {
633
578
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
634
579
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
635
580
  }
636
- let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
637
- let dragFrame = resolvedDragVisualizationFrame(
638
- app: activeApp,
639
- x: dragPoints.x,
640
- y: dragPoints.y,
641
- x2: dragPoints.x2,
642
- y2: dragPoints.y2
581
+ return executeDragGesture(
582
+ activeApp: activeApp,
583
+ x: x,
584
+ y: y,
585
+ x2: x2,
586
+ y2: y2,
587
+ durationMs: command.durationMs,
588
+ synthesized: command.synthesized == true,
589
+ message: "dragged"
643
590
  )
644
- var fallback: GestureFallback?
645
- if command.synthesized == true {
646
- let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
647
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
648
- synthesizedDragAt(
649
- app: activeApp,
650
- x: dragPoints.x,
651
- y: dragPoints.y,
652
- x2: dragPoints.x2,
653
- y2: dragPoints.y2,
654
- durationMs: durationMs
591
+ case .scroll:
592
+ // Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame via
593
+ // resolvedTouchReferenceFrame, computes drag endpoints with the Swift port of
594
+ // buildScrollGesturePlan, then runs the same non-synthesized drag path scroll's drag used.
595
+ guard let direction = command.direction,
596
+ direction == "up" || direction == "down" || direction == "left" || direction == "right"
597
+ else {
598
+ return Response(
599
+ ok: false,
600
+ error: ErrorPayload(
601
+ code: "INVALID_ARGS",
602
+ message: "scroll requires direction up|down|left|right"
655
603
  )
656
- }
657
- if case .performed = outcome {
658
- return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
659
- }
660
- fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
661
- }
662
- let holdDuration = command.synthesized == true
663
- ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
664
- : coordinateDragHoldDuration()
665
- let (timing, outcome) = performGesture(activeApp) {
666
- dragAt(
667
- app: activeApp,
668
- x: dragPoints.x,
669
- y: dragPoints.y,
670
- x2: dragPoints.x2,
671
- y2: dragPoints.y2,
672
- holdDuration: holdDuration
673
604
  )
674
605
  }
675
- if let response = unsupportedResponse(for: outcome) {
676
- return response
677
- }
678
- return gestureResponse(
679
- message: "dragged",
680
- timing: timing,
681
- frame: .drag(dragFrame),
682
- fallback: fallback
683
- )
684
- case .dragSeries:
685
- guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
686
- return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
687
- }
688
- let count = max(Int(command.count ?? 1), 1)
689
- let pauseMs = max(command.pauseMs ?? 0, 0)
690
- let pattern = command.pattern ?? "one-way"
691
- if pattern != "one-way" && pattern != "ping-pong" {
692
- return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
693
- }
694
- let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
695
- var fallback: GestureFallback?
696
- if command.synthesized == true {
697
- let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
698
- let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
699
- performDragSeries(
700
- count: count,
701
- pauseMs: pauseMs,
702
- pattern: pattern,
703
- points: dragPoints
704
- ) { startX, startY, endX, endY in
705
- synthesizedDragAt(
706
- app: activeApp,
707
- x: startX,
708
- y: startY,
709
- x2: endX,
710
- y2: endY,
711
- durationMs: durationMs
712
- )
713
- }
714
- }
715
- if case .performed = outcome {
716
- return gestureResponse(message: "drag series", timing: timing)
717
- }
718
- fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
606
+ let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
607
+ guard frame.width > 0, frame.height > 0 else {
608
+ return Response(
609
+ ok: false,
610
+ error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
611
+ )
719
612
  }
720
- let holdDuration = command.synthesized == true
721
- ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
722
- : coordinateDragHoldDuration()
723
- let (timing, outcome) = performGesture(activeApp) {
724
- performDragSeries(
725
- count: count,
726
- pauseMs: pauseMs,
727
- pattern: pattern,
728
- points: dragPoints
729
- ) { startX, startY, endX, endY in
730
- dragAt(
731
- app: activeApp,
732
- x: startX,
733
- y: startY,
734
- x2: endX,
735
- y2: endY,
736
- holdDuration: holdDuration
613
+ guard let plan = runnerScrollGesturePlan(
614
+ direction: direction,
615
+ amount: command.amount,
616
+ pixels: command.pixels,
617
+ referenceWidth: frame.width,
618
+ referenceHeight: frame.height
619
+ ) else {
620
+ return Response(
621
+ ok: false,
622
+ error: ErrorPayload(
623
+ code: "INVALID_ARGS",
624
+ message: "scroll could not compute a gesture plan"
737
625
  )
738
- }
739
- }
740
- if let response = unsupportedResponse(for: outcome) {
741
- return response
626
+ )
742
627
  }
743
- return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
628
+ return executeDragGesture(
629
+ activeApp: activeApp,
630
+ x: frame.minX + plan.x1,
631
+ y: frame.minY + plan.y1,
632
+ x2: frame.minX + plan.x2,
633
+ y2: frame.minY + plan.y2,
634
+ durationMs: nil,
635
+ synthesized: false,
636
+ message: "scrolled"
637
+ )
744
638
  case .remotePress:
745
639
  guard let button = tvRemoteButton(from: command.remoteButton) else {
746
640
  return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
@@ -759,17 +653,6 @@ extension RunnerTests {
759
653
  response = executeTypeCommand(activeApp: activeApp, command: command)
760
654
  }
761
655
  return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
762
- case .interactionFrame:
763
- let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
764
- return Response(
765
- ok: true,
766
- data: DataPayload(
767
- x: frame.minX,
768
- y: frame.minY,
769
- referenceWidth: frame.width,
770
- referenceHeight: frame.height
771
- )
772
- )
773
656
  case .swipe:
774
657
  guard let direction = command.direction else {
775
658
  return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
@@ -808,7 +691,6 @@ extension RunnerTests {
808
691
  case .snapshot:
809
692
  let options = SnapshotOptions(
810
693
  interactiveOnly: command.interactiveOnly ?? false,
811
- compact: command.compact ?? false,
812
694
  depth: command.depth,
813
695
  scope: command.scope,
814
696
  raw: command.raw ?? false
@@ -960,6 +842,8 @@ extension RunnerTests {
960
842
  return response
961
843
  }
962
844
  return gestureResponse(message: "pinched", timing: timing)
845
+ case .sequence:
846
+ return executeSequence(command: command, activeApp: activeApp)
963
847
  case .rotateGesture:
964
848
  guard let degrees = command.degrees, degrees.isFinite else {
965
849
  return Response(ok: false, error: ErrorPayload(message: "rotateGesture requires degrees"))
@@ -1023,6 +907,71 @@ extension RunnerTests {
1023
907
  }
1024
908
  }
1025
909
 
910
+ /// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
911
+ /// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
912
+ /// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
913
+ /// gestureResponse(.drag). `.scroll` always passes synthesized: false, pinning the same
914
+ /// non-synthesized drag path scroll's drag used today.
915
+ private func executeDragGesture(
916
+ activeApp: XCUIApplication,
917
+ x: Double,
918
+ y: Double,
919
+ x2: Double,
920
+ y2: Double,
921
+ durationMs: Double?,
922
+ synthesized: Bool,
923
+ message: String
924
+ ) -> Response {
925
+ let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
926
+ let dragFrame = resolvedDragVisualizationFrame(
927
+ app: activeApp,
928
+ x: dragPoints.x,
929
+ y: dragPoints.y,
930
+ x2: dragPoints.x2,
931
+ y2: dragPoints.y2
932
+ )
933
+ var fallback: GestureFallback?
934
+ if synthesized {
935
+ let durationMs = min(max(durationMs ?? 250, 16), 10000)
936
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
937
+ synthesizedDragAt(
938
+ app: activeApp,
939
+ x: dragPoints.x,
940
+ y: dragPoints.y,
941
+ x2: dragPoints.x2,
942
+ y2: dragPoints.y2,
943
+ durationMs: durationMs
944
+ )
945
+ }
946
+ if case .performed = outcome {
947
+ return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
948
+ }
949
+ fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
950
+ }
951
+ let holdDuration = synthesized
952
+ ? synthesizedSwipeFallbackHoldDuration(durationMs: durationMs ?? 250)
953
+ : coordinateDragHoldDuration()
954
+ let (timing, outcome) = performGesture(activeApp) {
955
+ dragAt(
956
+ app: activeApp,
957
+ x: dragPoints.x,
958
+ y: dragPoints.y,
959
+ x2: dragPoints.x2,
960
+ y2: dragPoints.y2,
961
+ holdDuration: holdDuration
962
+ )
963
+ }
964
+ if let response = unsupportedResponse(for: outcome) {
965
+ return response
966
+ }
967
+ return gestureResponse(
968
+ message: message,
969
+ timing: timing,
970
+ frame: .drag(dragFrame),
971
+ fallback: fallback
972
+ )
973
+ }
974
+
1026
975
  private func currentXCTestFailureCount() -> Int {
1027
976
  return testRun?.failureCount ?? 0
1028
977
  }
@@ -143,11 +143,11 @@ final class RunnerCommandJournal {
143
143
  switch command.command {
144
144
  case .snapshot, .screenshot:
145
145
  return false
146
- case .tap, .mouseClick, .tapSeries, .longPress, .interactionFrame, .drag, .dragSeries,
147
- .remotePress, .type, .swipe, .findText, .querySelector, .readText, .back, .backInApp,
148
- .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn, .alert,
149
- .pinch, .rotateGesture, .transformGesture, .recordStart, .recordStop, .status, .uptime,
150
- .shutdown:
146
+ case .tap, .mouseClick, .longPress, .drag,
147
+ .remotePress, .type, .swipe, .scroll, .findText, .querySelector, .readText, .back,
148
+ .backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
149
+ .alert, .pinch, .sequence, .rotateGesture, .transformGesture, .recordStart, .recordStop,
150
+ .status, .uptime, .shutdown:
151
151
  return true
152
152
  }
153
153
  }
@@ -165,6 +165,46 @@ extension RunnerTests {
165
165
  XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.notAccepted.rawValue)
166
166
  }
167
167
 
168
+ func testStampingCurrentUptimePreservesPayload() {
169
+ let stamped = Response(ok: true, data: DataPayload(message: "recording started"))
170
+ .stampingCurrentUptimeMs(123.5)
171
+
172
+ XCTAssertEqual(stamped.ok, true)
173
+ XCTAssertEqual(stamped.data?.message, "recording started")
174
+ XCTAssertEqual(stamped.data?.currentUptimeMs, 123.5)
175
+ }
176
+
177
+ func testStampingCurrentUptimeCreatesPayloadWhenNil() {
178
+ let stamped = Response(ok: true).stampingCurrentUptimeMs(456.0)
179
+
180
+ XCTAssertEqual(stamped.ok, true)
181
+ XCTAssertEqual(stamped.data?.currentUptimeMs, 456.0)
182
+ }
183
+
184
+ func testStampingCurrentUptimeSkipsErrorResponses() {
185
+ let response = Response(ok: false, error: ErrorPayload(message: "boom"))
186
+ let stamped = response.stampingCurrentUptimeMs(789.0)
187
+
188
+ XCTAssertEqual(stamped.ok, false)
189
+ XCTAssertNil(stamped.data)
190
+ XCTAssertEqual(stamped.error?.message, "boom")
191
+ }
192
+
193
+ func testJournalStoredResponseStaysUnstamped() throws {
194
+ let journal = RunnerCommandJournal()
195
+ let recordStart = runnerJournalCommand("recordStart", id: "record-start-anchor")
196
+
197
+ journal.accept(command: recordStart)
198
+ journal.finish(
199
+ command: recordStart,
200
+ response: Response(ok: true, data: DataPayload(message: "recording started"))
201
+ )
202
+
203
+ let status = journal.status(commandId: "record-start-anchor")
204
+ let responseJson = try XCTUnwrap(status.lifecycleResponseJson)
205
+ XCTAssertFalse(responseJson.contains("currentUptimeMs"))
206
+ }
207
+
168
208
  func testCommandJournalRetentionPolicy() throws {
169
209
  let journal = RunnerCommandJournal()
170
210
 
@@ -219,6 +259,38 @@ extension RunnerTests {
219
259
  XCTAssertEqual(screenshotStatus.lifecycleResponseOk, true)
220
260
  XCTAssertNil(screenshotStatus.lifecycleResponseJson)
221
261
 
262
+ let scroll = runnerJournalCommand("scroll", id: "scroll-drag")
263
+ journal.accept(command: scroll)
264
+ journal.finish(
265
+ command: scroll,
266
+ response: Response(
267
+ ok: true,
268
+ data: DataPayload(
269
+ message: "scrolled",
270
+ gestureStartUptimeMs: 1,
271
+ gestureEndUptimeMs: 2,
272
+ x: 155,
273
+ y: 420,
274
+ x2: 155,
275
+ y2: 301,
276
+ referenceWidth: 300,
277
+ referenceHeight: 600
278
+ )
279
+ )
280
+ )
281
+
282
+ let scrollStatus = journal.status(commandId: "scroll-drag")
283
+ XCTAssertEqual(scrollStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
284
+ XCTAssertEqual(scrollStatus.lifecycleResponseOk, true)
285
+ XCTAssertNotNil(scrollStatus.lifecycleResponseJson)
286
+ let scrollResponse = try decodeRunnerJournalResponse(scrollStatus.lifecycleResponseJson)
287
+ XCTAssertEqual(scrollResponse.data?.x, 155)
288
+ XCTAssertEqual(scrollResponse.data?.y, 420)
289
+ XCTAssertEqual(scrollResponse.data?.x2, 155)
290
+ XCTAssertEqual(scrollResponse.data?.y2, 301)
291
+ XCTAssertEqual(scrollResponse.data?.referenceWidth, 300)
292
+ XCTAssertEqual(scrollResponse.data?.referenceHeight, 600)
293
+
222
294
  let largeRead = runnerJournalCommand("readText", id: "large-read")
223
295
  journal.accept(command: largeRead)
224
296
  journal.finish(
@@ -262,6 +334,82 @@ extension RunnerTests {
262
334
  XCTAssertEqual(status.lifecycleErrorHint, hint)
263
335
  }
264
336
 
337
+ func testCommandJournalRetainsCompletedSequenceResults() throws {
338
+ let journal = RunnerCommandJournal()
339
+ let sequence = runnerJournalCommand("sequence", id: "sequence-completed")
340
+ let results = (0..<20).map { _ in
341
+ SequenceStepResult(
342
+ ok: true,
343
+ kind: "tap",
344
+ errorCode: nil,
345
+ errorMessage: nil,
346
+ gestureStartUptimeMs: 100,
347
+ gestureEndUptimeMs: 120
348
+ )
349
+ }
350
+
351
+ journal.accept(command: sequence)
352
+ journal.finish(
353
+ command: sequence,
354
+ response: Response(
355
+ ok: true,
356
+ data: DataPayload(
357
+ message: "sequence",
358
+ completedSteps: 20,
359
+ failedStepIndex: nil,
360
+ sequenceResults: results
361
+ )
362
+ )
363
+ )
364
+
365
+ let status = journal.status(commandId: "sequence-completed")
366
+ XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
367
+ XCTAssertEqual(status.lifecycleResponseOk, true)
368
+ let json = try XCTUnwrap(status.lifecycleResponseJson)
369
+ // Worst-case 20-step response must stay under the 16KB journal retention cap.
370
+ XCTAssertLessThan(json.utf8.count, 16 * 1024)
371
+ let decoded = try decodeRunnerJournalResponse(status.lifecycleResponseJson)
372
+ XCTAssertEqual(decoded.data?.completedSteps, 20)
373
+ XCTAssertEqual(decoded.data?.sequenceResults?.count, 20)
374
+ }
375
+
376
+ func testCommandJournalRetainsFailedSequenceResults() throws {
377
+ let journal = RunnerCommandJournal()
378
+ let sequence = runnerJournalCommand("sequence", id: "sequence-failed")
379
+ let longError = String(repeating: "z", count: 200)
380
+ let results: [SequenceStepResult] = [
381
+ SequenceStepResult(ok: true, kind: "tap", errorCode: nil, errorMessage: nil,
382
+ gestureStartUptimeMs: 100, gestureEndUptimeMs: 120),
383
+ SequenceStepResult(ok: true, kind: "tap", errorCode: nil, errorMessage: nil,
384
+ gestureStartUptimeMs: 130, gestureEndUptimeMs: 150),
385
+ SequenceStepResult(ok: false, kind: "drag", errorCode: "UNSUPPORTED_OPERATION",
386
+ errorMessage: longError, gestureStartUptimeMs: 160, gestureEndUptimeMs: 180),
387
+ ]
388
+
389
+ journal.accept(command: sequence)
390
+ journal.finish(
391
+ command: sequence,
392
+ response: Response(
393
+ ok: true,
394
+ data: DataPayload(
395
+ message: "sequence",
396
+ completedSteps: 2,
397
+ failedStepIndex: 2,
398
+ sequenceResults: results
399
+ )
400
+ )
401
+ )
402
+
403
+ let status = journal.status(commandId: "sequence-failed")
404
+ XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
405
+ let decoded = try decodeRunnerJournalResponse(status.lifecycleResponseJson)
406
+ XCTAssertEqual(decoded.data?.completedSteps, 2)
407
+ XCTAssertEqual(decoded.data?.failedStepIndex, 2)
408
+ XCTAssertEqual(decoded.data?.sequenceResults?.count, 3)
409
+ XCTAssertEqual(decoded.data?.sequenceResults?[2].ok, false)
410
+ XCTAssertEqual(decoded.data?.sequenceResults?[2].errorCode, "UNSUPPORTED_OPERATION")
411
+ }
412
+
265
413
  private func runnerJournalCommand(_ command: String, id: String) -> Command {
266
414
  let json = #"{"command":"\#(command)","commandId":"\#(id)"}"#
267
415
  return try! JSONDecoder().decode(Command.self, from: Data(json.utf8))