agent-device 0.19.0 → 0.19.1

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 (84) hide show
  1. package/README.md +5 -1
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.apk → agent-device-android-multitouch-helper-0.19.1.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.1.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.manifest.json → agent-device-android-multitouch-helper-0.19.1.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.apk → agent-device-android-snapshot-helper-0.19.1.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.1.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
  8. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +24 -7
  9. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +506 -119
  10. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +153 -48
  11. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
  12. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -5
  13. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +60 -52
  14. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +159 -56
  15. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
  16. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -14
  17. package/dist/src/android-adb.d.ts +1 -2
  18. package/dist/src/android-adb.js +1 -1
  19. package/dist/src/android-input-ownership.js +6 -0
  20. package/dist/src/android.js +1 -1
  21. package/dist/src/app-lifecycle.js +3 -3
  22. package/dist/src/apple-runner-options.js +1 -1
  23. package/dist/src/apps.js +5 -5
  24. package/dist/src/cli-help.js +131 -34
  25. package/dist/src/cli-runner.js +1 -1
  26. package/dist/src/cli.js +11 -11
  27. package/dist/src/client.js +2 -2
  28. package/dist/src/command-metadata.js +1 -1
  29. package/dist/src/command-schema.js +3 -3
  30. package/dist/src/device-input-state.js +2 -2
  31. package/dist/src/devices2.js +1 -1
  32. package/dist/src/find.js +1 -1
  33. package/dist/src/generic.js +1 -1
  34. package/dist/src/index.d.ts +199 -20
  35. package/dist/src/input-actions.js +3 -3
  36. package/dist/src/interaction-snapshot.js +1 -1
  37. package/dist/src/interaction.js +1 -1
  38. package/dist/src/interactor.js +2 -2
  39. package/dist/src/internal/bin.js +1 -1
  40. package/dist/src/internal/daemon.js +17 -15
  41. package/dist/src/lease.js +1 -1
  42. package/dist/src/linux.js +1 -1
  43. package/dist/src/metro.d.ts +0 -1
  44. package/dist/src/notifications.js +1 -1
  45. package/dist/src/points.js +1 -1
  46. package/dist/src/project-runtime.js +1 -1
  47. package/dist/src/providers.js +1 -1
  48. package/dist/src/react-native.js +1 -1
  49. package/dist/src/record-trace-recording.js +2 -2
  50. package/dist/src/record-trace.js +1 -1
  51. package/dist/src/registry.js +24 -23
  52. package/dist/src/reporting.js +3 -3
  53. package/dist/src/runner-client.js +1 -1
  54. package/dist/src/runtime.js +2 -2
  55. package/dist/src/sdk-android-adb.d.ts +2 -2
  56. package/dist/src/sdk-batch-runner.d.ts +0 -1
  57. package/dist/src/sdk-batch.js +2 -2
  58. package/dist/src/sdk-finders.d.ts +1 -2
  59. package/dist/src/sdk-finders.js +1 -1
  60. package/dist/src/sdk-io.d.ts +0 -1
  61. package/dist/src/sdk-remote-config.d.ts +1 -2
  62. package/dist/src/sdk-remote-config.js +1 -1
  63. package/dist/src/sdk-selectors.js +1 -1
  64. package/dist/src/selector-runtime.js +1 -1
  65. package/dist/src/selectors.d.ts +0 -1
  66. package/dist/src/selectors.js +1 -1
  67. package/dist/src/selectors2.js +1 -1
  68. package/dist/src/server.js +3 -3
  69. package/dist/src/session.js +10 -10
  70. package/dist/src/simctl.js +1 -1
  71. package/dist/src/simulator.js +1 -1
  72. package/dist/src/snapshot-diagnostics.js +2 -2
  73. package/dist/src/snapshot-helper.js +4 -4
  74. package/dist/src/snapshot-quality.js +1 -1
  75. package/dist/src/snapshot2.js +2 -2
  76. package/dist/src/tv-remote.js +14 -0
  77. package/dist/src/web.js +1 -1
  78. package/package.json +5 -4
  79. package/server.json +2 -2
  80. package/skills/agent-device/SKILL.md +8 -7
  81. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.0.apk.sha256 +0 -1
  82. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.0.apk.sha256 +0 -1
  83. package/dist/src/device-rotation.js +0 -14
  84. package/dist/src/logcat.js +0 -6
@@ -36,34 +36,21 @@ extension RunnerTests {
36
36
  let y2: Double
37
37
  }
38
38
 
39
+ struct SynthesizedDragPlan {
40
+ let points: DragPoints
41
+ let context: SynthesizedCoordinateContext
42
+
43
+ var referenceFrame: CGRect {
44
+ context.referenceFrame
45
+ }
46
+ }
47
+
39
48
  struct SelectorElementMatch {
40
49
  let element: XCUIElement?
41
50
  let isAmbiguous: Bool
42
51
  let usedNonHittableFallback: Bool
43
52
  }
44
53
 
45
- // MARK: - Navigation Gestures
46
-
47
- func tapInAppBackControl(app: XCUIApplication) -> Bool {
48
- #if os(macOS)
49
- if let back = macOSNavigationBackElement(app: app) {
50
- tapElementCenter(app: app, element: back)
51
- return true
52
- }
53
- return false
54
- #elseif os(tvOS)
55
- _ = pressTvRemote(.menu)
56
- return true
57
- #else
58
- let buttons = app.navigationBars.buttons.allElementsBoundByIndex
59
- if let back = buttons.first(where: { $0.isHittable }) {
60
- back.tap()
61
- return true
62
- }
63
- return false
64
- #endif
65
- }
66
-
67
54
  func performBackGesture(app: XCUIApplication) {
68
55
  if pressTvRemote(.menu) {
69
56
  return
@@ -787,11 +774,24 @@ extension RunnerTests {
787
774
  y: Double,
788
775
  x2: Double,
789
776
  y2: Double,
790
- durationMs: Double
777
+ durationMs: Double,
778
+ context: SynthesizedCoordinateContext? = nil
791
779
  ) -> RunnerInteractionOutcome {
792
780
  #if os(iOS)
781
+ guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
782
+ return .unsupported(
783
+ message: "synthesized coordinate drag requires finite coordinates",
784
+ hint: "Retry with finite x, y, x2, and y2 values."
785
+ )
786
+ }
793
787
  let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
794
- let frame = app.frame
788
+ guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag)) else {
789
+ return .unsupported(
790
+ message: "synthesized coordinate drag could not resolve a finite screen frame",
791
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
792
+ )
793
+ }
794
+ let frame = context.referenceFrame
795
795
  let start = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
796
796
  let end = nativeSynthesizedPoint(orientedX: x2, orientedY: y2, in: frame, interfaceOrientation: orientation)
797
797
  if let message = RunnerSynthesizedGesture.synthesizeSwipe(
@@ -804,7 +804,7 @@ extension RunnerTests {
804
804
  ) {
805
805
  return .unsupported(
806
806
  message: message,
807
- hint: "Falling back to XCTest coordinate drag may be slower; update Xcode if this persists."
807
+ hint: "Private XCTest event synthesis is required for AX-free coordinate drag on iOS; update Xcode if this persists."
808
808
  )
809
809
  }
810
810
  return .performed
@@ -821,10 +821,28 @@ extension RunnerTests {
821
821
  #endif
822
822
  }
823
823
 
824
- func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
824
+ func synthesizedTapAt(
825
+ app: XCUIApplication,
826
+ x: Double,
827
+ y: Double,
828
+ context: SynthesizedCoordinateContext? = nil
829
+ ) -> RunnerInteractionOutcome {
825
830
  #if os(iOS)
831
+ guard x.isFinite, y.isFinite else {
832
+ return .unsupported(
833
+ message: "synthesized coordinate tap requires finite coordinates",
834
+ hint: "Retry with finite x and y values."
835
+ )
836
+ }
826
837
  let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
827
- let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: app.frame, interfaceOrientation: orientation)
838
+ guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.coordinateTap)) else {
839
+ return .unsupported(
840
+ message: "synthesized coordinate tap could not resolve a finite screen frame",
841
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
842
+ )
843
+ }
844
+ let frame = context.referenceFrame
845
+ let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
828
846
  if let message = RunnerSynthesizedGesture.synthesizeTap(
829
847
  withApplication: app,
830
848
  x: Double(point.x),
@@ -994,6 +1012,114 @@ extension RunnerTests {
994
1012
  #endif
995
1013
  }
996
1014
 
1015
+ func axFreeSynthesizedDragPlan(
1016
+ app: XCUIApplication,
1017
+ x: Double,
1018
+ y: Double,
1019
+ x2: Double,
1020
+ y2: Double,
1021
+ context: SynthesizedCoordinateContext? = nil
1022
+ ) -> SynthesizedDragPlan? {
1023
+ #if os(iOS)
1024
+ let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
1025
+ guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite,
1026
+ let context
1027
+ else {
1028
+ return nil
1029
+ }
1030
+ let points = keyboardAvoidingSynthesizedDragPoints(
1031
+ app: app,
1032
+ x: x,
1033
+ y: y,
1034
+ x2: x2,
1035
+ y2: y2,
1036
+ context: context
1037
+ )
1038
+ return SynthesizedDragPlan(
1039
+ points: points,
1040
+ context: context
1041
+ )
1042
+ #else
1043
+ return nil
1044
+ #endif
1045
+ }
1046
+
1047
+ func axFreeDragVisualizationFrame(
1048
+ x: Double,
1049
+ y: Double,
1050
+ x2: Double,
1051
+ y2: Double,
1052
+ referenceFrame: CGRect
1053
+ ) -> DragVisualizationFrame {
1054
+ return DragVisualizationFrame(
1055
+ x: x,
1056
+ y: y,
1057
+ x2: x2,
1058
+ y2: y2,
1059
+ referenceWidth: Double(referenceFrame.width),
1060
+ referenceHeight: Double(referenceFrame.height)
1061
+ )
1062
+ }
1063
+
1064
+ func synthesizedCoordinateContext(policy: SynthesizedGesturePolicy) -> SynthesizedCoordinateContext? {
1065
+ #if os(iOS)
1066
+ let health = runnerAccessibilityHealth
1067
+ guard let referenceFrame = synthesizedScreenshotReferenceFrame(
1068
+ screenshotSize: { XCUIScreen.main.screenshot().image.size }
1069
+ ) else {
1070
+ return nil
1071
+ }
1072
+ return SynthesizedCoordinateContext(
1073
+ referenceFrame: referenceFrame,
1074
+ keyboardPolicy: policy.keyboardPolicy,
1075
+ fallbackPolicy: policy.fallbackPolicy,
1076
+ accessibilityHealth: health
1077
+ )
1078
+ #else
1079
+ return nil
1080
+ #endif
1081
+ }
1082
+
1083
+ func synthesizedScreenshotReferenceFrame(screenshotSize: () -> CGSize) -> CGRect? {
1084
+ let screenshotSize = screenshotSize()
1085
+ guard screenshotSize.width.isFinite, screenshotSize.height.isFinite,
1086
+ screenshotSize.width > 0,
1087
+ screenshotSize.height > 0
1088
+ else {
1089
+ return nil
1090
+ }
1091
+ return CGRect(x: 0, y: 0, width: screenshotSize.width, height: screenshotSize.height)
1092
+ }
1093
+
1094
+ func synthesizedFrameAvoidingKeyboardWhenAllowed(
1095
+ app: XCUIApplication,
1096
+ context: SynthesizedCoordinateContext
1097
+ ) -> CGRect {
1098
+ #if os(iOS)
1099
+ guard context.allowsKeyboardProbe else { return context.referenceFrame }
1100
+ return frameAvoidingKeyboard(app: app, frame: context.referenceFrame)
1101
+ #else
1102
+ return context.referenceFrame
1103
+ #endif
1104
+ }
1105
+
1106
+ func keyboardAvoidingSynthesizedDragPoints(
1107
+ app: XCUIApplication,
1108
+ x: Double,
1109
+ y: Double,
1110
+ x2: Double,
1111
+ y2: Double,
1112
+ context: SynthesizedCoordinateContext
1113
+ ) -> DragPoints {
1114
+ #if os(iOS)
1115
+ guard context.allowsKeyboardProbe else {
1116
+ return DragPoints(x: x, y: y, x2: x2, y2: y2)
1117
+ }
1118
+ return keyboardAvoidingDragPoints(app: app, x: x, y: y, x2: x2, y2: y2)
1119
+ #else
1120
+ return DragPoints(x: x, y: y, x2: x2, y2: y2)
1121
+ #endif
1122
+ }
997
1123
 
998
1124
  func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
999
1125
  if performTvRemoteSwipeIfAvailable(direction: direction) {
@@ -1227,25 +1353,4 @@ extension RunnerTests {
1227
1353
  }
1228
1354
  #endif
1229
1355
 
1230
- private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
1231
- let frame = element.frame
1232
- if !frame.isEmpty {
1233
- _ = tapAt(app: app, x: frame.midX, y: frame.midY)
1234
- return
1235
- }
1236
- #if !os(tvOS)
1237
- element.tap()
1238
- #endif
1239
- }
1240
-
1241
- private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
1242
- let predicate = NSPredicate(
1243
- format: "identifier == %@ OR label == %@",
1244
- "go back",
1245
- "Back"
1246
- )
1247
- let element = app.descendants(matching: .any).matching(predicate).firstMatch
1248
- return element.exists ? element : nil
1249
- }
1250
-
1251
1356
  }
@@ -0,0 +1,172 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ static let navigationBackKeywords = ["back", "close", "cancel"]
5
+ static let navigationFallbackVerificationDelay: TimeInterval = 0.25
6
+
7
+ func tapInAppBackControl(app: XCUIApplication) -> Bool {
8
+ #if os(macOS)
9
+ if let back = macOSNavigationBackElement(app: app) {
10
+ tapElementCenter(app: app, element: back)
11
+ return true
12
+ }
13
+ return false
14
+ #elseif os(tvOS)
15
+ _ = pressTvRemote(.menu)
16
+ return true
17
+ #else
18
+ let buttons = app.navigationBars.buttons.allElementsBoundByIndex
19
+ if let back = buttons.first(where: { $0.isHittable }) {
20
+ back.tap()
21
+ return true
22
+ }
23
+ if isSnapshotXCTestChannelPenalized(bundleId: currentBundleId) {
24
+ NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_SKIPPED_XCTEST_ENUMERATION bundle=%@", currentBundleId ?? "")
25
+ } else if let back = topNavigationBackElement(app: app) {
26
+ tapElementCenter(app: app, element: back)
27
+ return true
28
+ }
29
+ return tapTopLeadingNavigationFallback(app: app)
30
+ #endif
31
+ }
32
+
33
+ private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
34
+ let frame = element.frame
35
+ if !frame.isEmpty {
36
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
37
+ return
38
+ }
39
+ #if !os(tvOS)
40
+ element.tap()
41
+ #endif
42
+ }
43
+
44
+ private func topNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
45
+ #if os(iOS)
46
+ let frame = onScreenWindowFrame(app: app)
47
+ let candidates = app.buttons.matching(Self.navigationBackPredicate()).allElementsBoundByIndex.compactMap {
48
+ element -> (XCUIElement, Int)? in
49
+ guard element.exists, element.isHittable else { return nil }
50
+ guard Self.isTopNavigationControlFrame(element.frame, in: frame) else { return nil }
51
+ guard let rank = Self.navigationBackControlRank(
52
+ label: element.label,
53
+ identifier: element.identifier
54
+ ) else {
55
+ return nil
56
+ }
57
+ return (element, rank)
58
+ }
59
+ return candidates.sorted { lhs, rhs in
60
+ if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
61
+ let leftFrame = lhs.0.frame
62
+ let rightFrame = rhs.0.frame
63
+ if leftFrame.minY != rightFrame.minY { return leftFrame.minY < rightFrame.minY }
64
+ return leftFrame.minX < rightFrame.minX
65
+ }.first?.0
66
+ #else
67
+ return nil
68
+ #endif
69
+ }
70
+
71
+ static func navigationBackPredicate() -> NSPredicate {
72
+ let clauses = navigationBackKeywords.flatMap { _ in
73
+ ["label CONTAINS[c] %@", "identifier CONTAINS[c] %@"]
74
+ }.joined(separator: " OR ")
75
+ let arguments = navigationBackKeywords.flatMap { [$0, $0] }
76
+ return NSPredicate(format: clauses, argumentArray: arguments)
77
+ }
78
+
79
+ static func navigationBackControlRank(label: String, identifier: String) -> Int? {
80
+ let text = "\(label) \(identifier)".lowercased()
81
+ return navigationBackKeywords.firstIndex { text.contains($0) }
82
+ }
83
+
84
+ static func isTopNavigationControlFrame(_ candidate: CGRect, in window: CGRect) -> Bool {
85
+ guard
86
+ candidate.width.isFinite,
87
+ candidate.height.isFinite,
88
+ window.width.isFinite,
89
+ window.height.isFinite,
90
+ candidate.width > 0,
91
+ candidate.height > 0,
92
+ window.width > 0,
93
+ window.height > 0
94
+ else {
95
+ return false
96
+ }
97
+ // Accept the compact navigation/search header band without matching deep content controls.
98
+ let maxY = window.minY + min(max(window.height * 0.22, 96), 180)
99
+ return candidate.midY >= window.minY && candidate.midY <= maxY
100
+ }
101
+
102
+ static func topLeadingNavigationFallbackPoint(in frame: CGRect) -> CGPoint? {
103
+ guard frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0 else {
104
+ return nil
105
+ }
106
+ // Aim at the standard leading navigation slot, bounded for compact and tablet widths.
107
+ let xOffset = min(max(frame.width * 0.08, 28), 44)
108
+ // Sit below the status/dynamic-island region and inside common custom RN search headers.
109
+ let yOffset = min(max(frame.height * 0.155, 56), 132)
110
+ return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset)
111
+ }
112
+
113
+ private func tapTopLeadingNavigationFallback(app: XCUIApplication) -> Bool {
114
+ #if os(iOS)
115
+ let frame = onScreenWindowFrame(app: app)
116
+ guard let point = Self.topLeadingNavigationFallbackPoint(in: frame) else {
117
+ return false
118
+ }
119
+ let before = captureNavigationFallbackVisualState()
120
+ let context = synthesizedCoordinateContext(
121
+ policy: synthesizedGesturePolicy(.coordinateTap)
122
+ )?.withReferenceFrame(frame)
123
+ let synthesized = performGesture(app, idleTimeout: false) {
124
+ synthesizedTapAt(app: app, x: point.x, y: point.y, context: context)
125
+ }
126
+ if case .performed = synthesized.outcome {
127
+ return didNavigationFallbackChangeVisualState(before: before)
128
+ }
129
+ let fallback = performGesture(app) {
130
+ tapAt(app: app, x: point.x, y: point.y)
131
+ }
132
+ if case .performed = fallback.outcome {
133
+ return didNavigationFallbackChangeVisualState(before: before)
134
+ }
135
+ #endif
136
+ return false
137
+ }
138
+
139
+ private func captureNavigationFallbackVisualState() -> Data? {
140
+ #if os(iOS)
141
+ runnerPngData(for: XCUIScreen.main.screenshot().image)
142
+ #else
143
+ return nil
144
+ #endif
145
+ }
146
+
147
+ private func didNavigationFallbackChangeVisualState(before: Data?) -> Bool {
148
+ sleepFor(Self.navigationFallbackVerificationDelay)
149
+ let after = captureNavigationFallbackVisualState()
150
+ let changed = Self.didNavigationFallbackChangeVisualState(before: before, after: after)
151
+ if !changed {
152
+ NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_FALLBACK_NO_STATE_CHANGE")
153
+ }
154
+ return changed
155
+ }
156
+
157
+ static func didNavigationFallbackChangeVisualState(before: Data?, after: Data?) -> Bool {
158
+ guard let before, let after else { return false }
159
+ return before != after
160
+ }
161
+
162
+ private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
163
+ let predicate = NSPredicate(
164
+ format: "identifier == %@ OR label == %@",
165
+ "go back",
166
+ "Back"
167
+ )
168
+ let element = app.descendants(matching: .any).matching(predicate).firstMatch
169
+ return element.exists ? element : nil
170
+ }
171
+
172
+ }
@@ -40,8 +40,10 @@ extension RunnerTests {
40
40
  ? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!)
41
41
  : nil
42
42
 
43
+ let synthesizedContext = synthesizedSequenceCoordinateContext(steps: steps)
44
+
43
45
  let execution = assembleSequenceExecution(steps: steps) { _, step in
44
- performSequenceStep(step, activeApp: activeApp)
46
+ performSequenceStep(step, activeApp: activeApp, synthesizedContext: synthesizedContext)
45
47
  }
46
48
  return sequenceResponse(execution: execution, touchFrame: firstFrame)
47
49
  }
@@ -141,7 +143,8 @@ extension RunnerTests {
141
143
 
142
144
  private func performSequenceStep(
143
145
  _ step: SequenceStep,
144
- activeApp: XCUIApplication
146
+ activeApp: XCUIApplication,
147
+ synthesizedContext: SynthesizedCoordinateContext? = nil
145
148
  ) -> SequenceStepOutcome {
146
149
  let x = step.x ?? 0
147
150
  let y = step.y ?? 0
@@ -149,10 +152,26 @@ extension RunnerTests {
149
152
  // a tapAt fallback when synthesis is unsupported), so fusing a jittered tap series does not
150
153
  // change the touch mechanism for these inputs.
151
154
  if step.kind == "tap", step.synthesized == true {
155
+ let policyKind = SynthesizedGesturePolicyKind.synthesizedDrag
156
+ #if os(iOS)
157
+ guard let synthesizedContext else {
158
+ let nowMs = ProcessInfo.processInfo.systemUptime * 1000
159
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: nil, fallbackAttempted: false)
160
+ return SequenceStepOutcome(
161
+ outcome: .unsupported(
162
+ message: "synthesized coordinate tap could not resolve a finite coordinate frame",
163
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
164
+ ),
165
+ gestureStartUptimeMs: nowMs,
166
+ gestureEndUptimeMs: nowMs
167
+ )
168
+ }
169
+ #endif
152
170
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
153
- synthesizedTapAt(app: activeApp, x: x, y: y)
171
+ synthesizedTapAt(app: activeApp, x: x, y: y, context: synthesizedContext)
154
172
  }
155
173
  if case .performed = outcome {
174
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: synthesizedContext, fallbackAttempted: false)
156
175
  if let pauseMs = step.pauseMs, pauseMs > 0 {
157
176
  sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
158
177
  }
@@ -162,11 +181,50 @@ extension RunnerTests {
162
181
  gestureEndUptimeMs: timing.gestureEndUptimeMs
163
182
  )
164
183
  }
184
+ #if os(iOS)
185
+ guard synthesizedContext.allowsXCTestCoordinateFallback else {
186
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: synthesizedContext, fallbackAttempted: false)
187
+ return SequenceStepOutcome(
188
+ outcome: outcome,
189
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
190
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
191
+ )
192
+ }
193
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: synthesizedContext, fallbackAttempted: true)
194
+ #endif
165
195
  // Synthesis unsupported (e.g. macOS) — fall through to the drag-based tapAt below.
166
196
  }
167
197
  if step.kind == "drag", step.synthesized == true {
168
- let dragPoints = keyboardAvoidingDragPoints(
198
+ let policyKind = SynthesizedGesturePolicyKind.synthesizedDrag
199
+ let dragPoints: DragPoints
200
+ let dragContext: SynthesizedCoordinateContext?
201
+ #if os(iOS)
202
+ guard let dragPlan = axFreeSynthesizedDragPlan(
203
+ app: activeApp,
204
+ x: x,
205
+ y: y,
206
+ x2: step.x2 ?? x,
207
+ y2: step.y2 ?? y,
208
+ context: synthesizedContext
209
+ ) else {
210
+ let nowMs = ProcessInfo.processInfo.systemUptime * 1000
211
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: synthesizedContext, fallbackAttempted: false)
212
+ return SequenceStepOutcome(
213
+ outcome: .unsupported(
214
+ message: "synthesized coordinate drag could not resolve a finite coordinate frame",
215
+ hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
216
+ ),
217
+ gestureStartUptimeMs: nowMs,
218
+ gestureEndUptimeMs: nowMs
219
+ )
220
+ }
221
+ dragPoints = dragPlan.points
222
+ dragContext = dragPlan.context
223
+ #else
224
+ dragPoints = keyboardAvoidingDragPoints(
169
225
  app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
226
+ dragContext = nil
227
+ #endif
170
228
  let durationMs = min(max(step.durationMs ?? 250, 16), 10000)
171
229
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
172
230
  synthesizedDragAt(
@@ -175,10 +233,12 @@ extension RunnerTests {
175
233
  y: dragPoints.y,
176
234
  x2: dragPoints.x2,
177
235
  y2: dragPoints.y2,
178
- durationMs: durationMs
236
+ durationMs: durationMs,
237
+ context: dragContext
179
238
  )
180
239
  }
181
240
  if case .performed = outcome {
241
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: dragContext, fallbackAttempted: false)
182
242
  if let pauseMs = step.pauseMs, pauseMs > 0 {
183
243
  sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
184
244
  }
@@ -188,6 +248,17 @@ extension RunnerTests {
188
248
  gestureEndUptimeMs: timing.gestureEndUptimeMs
189
249
  )
190
250
  }
251
+ #if os(iOS)
252
+ guard dragContext?.allowsXCTestCoordinateFallback == true else {
253
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: dragContext, fallbackAttempted: false)
254
+ return SequenceStepOutcome(
255
+ outcome: outcome,
256
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
257
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
258
+ )
259
+ }
260
+ #endif
261
+ logSynthesizedGesturePolicyDecision(kind: policyKind, context: dragContext, fallbackAttempted: true)
191
262
  let fallbackHoldDuration = synthesizedSwipeFallbackHoldDuration(durationMs: step.durationMs ?? 250)
192
263
  let (fallbackTiming, fallbackOutcome) = performGesture(activeApp) {
193
264
  dragAt(