agent-device 0.19.3 → 0.20.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 (179) hide show
  1. package/README.md +62 -70
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.apk +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.manifest.json +10 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.apk +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.apk.sha256 +1 -0
  7. package/{android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.manifest.json → android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.manifest.json} +6 -6
  8. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +2 -0
  9. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +5 -0
  10. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +5 -10
  11. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +74 -174
  12. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +113 -19
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift +82 -0
  14. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +325 -123
  15. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +2 -2
  16. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +149 -105
  17. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +56 -2
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +73 -0
  19. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +51 -26
  20. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +2 -2
  21. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +2 -107
  22. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +130 -13
  23. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +51 -8
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +24 -0
  25. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +1 -1
  26. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +43 -35
  27. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +1 -1
  28. package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -1
  29. package/dist/src/agent-browser-tool.js +1 -1
  30. package/dist/src/agent-device-client.js +3 -0
  31. package/dist/src/android-adb.d.ts +1 -1
  32. package/dist/src/android-adb.js +1 -1
  33. package/dist/src/android-input-method-overlays.js +1 -0
  34. package/dist/src/android-input-ownership.js +1 -6
  35. package/dist/src/android-system-chrome.js +1 -0
  36. package/dist/src/android.js +1 -1
  37. package/dist/src/app-lifecycle.js +5 -5
  38. package/dist/src/app-log-request-scope.js +1 -0
  39. package/dist/src/app-resolution.js +1 -0
  40. package/dist/src/apple-multitouch-support.js +1 -0
  41. package/dist/src/apps.js +5 -5
  42. package/dist/src/boot-diagnostics.js +2 -0
  43. package/dist/src/cli-config.js +1 -0
  44. package/dist/src/cli-help.js +131 -40
  45. package/dist/src/cli-runner.js +1 -1
  46. package/dist/src/cli.js +15 -11
  47. package/dist/src/command-metadata.js +1 -1
  48. package/dist/src/command-schema.js +3 -3
  49. package/dist/src/compatibility-policy.js +1 -0
  50. package/dist/src/context.js +1 -0
  51. package/dist/src/contracts.d.ts +2 -2
  52. package/dist/src/debug-symbols.js +1 -1
  53. package/dist/src/device-input-state.js +1 -1
  54. package/dist/src/devices2.js +1 -1
  55. package/dist/src/devices4.js +1 -0
  56. package/dist/src/find.js +1 -1
  57. package/dist/src/finders.js +1 -1
  58. package/dist/src/generic.js +1 -1
  59. package/dist/src/helper.js +1 -1
  60. package/dist/src/index.d.ts +533 -279
  61. package/dist/src/index.js +1 -1
  62. package/dist/src/input-actions.js +4 -3
  63. package/dist/src/install-artifact.js +1 -0
  64. package/dist/src/install-source.d.ts +5 -1
  65. package/dist/src/install-source.js +1 -1
  66. package/dist/src/install-source2.js +1 -1
  67. package/dist/src/interaction-snapshot.js +1 -1
  68. package/dist/src/interaction.d.ts +230 -0
  69. package/dist/src/interaction.js +1 -1
  70. package/dist/src/interactor.js +2 -2
  71. package/dist/src/interactor2.js +1 -0
  72. package/dist/src/internal/bin.js +1 -1
  73. package/dist/src/internal/daemon.js +42 -20
  74. package/dist/src/internal/png-worker.d.ts +22 -0
  75. package/dist/src/internal/png-worker.js +1 -1
  76. package/dist/src/launch-console.js +1 -0
  77. package/dist/src/lease-context.js +1 -0
  78. package/dist/src/lease.js +1 -1
  79. package/dist/src/linux.js +1 -1
  80. package/dist/src/logcat.js +5 -0
  81. package/dist/src/method-scope.js +1 -0
  82. package/dist/src/metro-types.d.ts +52 -0
  83. package/dist/src/metro.d.ts +3 -3
  84. package/dist/src/notifications.js +1 -1
  85. package/dist/src/png-worker-contract.js +1 -1
  86. package/dist/src/process-lock.js +1 -0
  87. package/dist/src/react-native.js +1 -1
  88. package/dist/src/record-trace.js +1 -1
  89. package/dist/src/recording-export-quality.js +15 -0
  90. package/dist/src/register-builtins.js +1 -0
  91. package/dist/src/registry.js +24 -24
  92. package/dist/src/remote-config.d.ts +1 -1
  93. package/dist/src/reporting.js +8 -7
  94. package/dist/src/runner-client.js +1 -1
  95. package/dist/src/runner-disposal.js +2 -0
  96. package/dist/src/runner-lease.js +1 -0
  97. package/dist/src/runner-provider.js +1 -1
  98. package/dist/src/runner-sequence.js +1 -1
  99. package/dist/src/runtime.js +3 -3
  100. package/dist/src/runtime2.js +1 -0
  101. package/dist/src/sdk-android-adb.d.ts +97 -33
  102. package/dist/src/sdk-android-adb.js +4 -4
  103. package/dist/src/sdk-batch-runner.d.ts +2 -1
  104. package/dist/src/sdk-batch-runner.js +3 -2
  105. package/dist/src/sdk-contracts.d.ts +70 -149
  106. package/dist/src/sdk-contracts.js +1 -1
  107. package/dist/src/sdk-device.js +1 -1
  108. package/dist/src/sdk-finders.d.ts +5 -4
  109. package/dist/src/sdk-finders.js +1 -1
  110. package/dist/src/sdk-io.d.ts +1 -1
  111. package/dist/src/sdk-remote-config.d.ts +19 -96
  112. package/dist/src/sdk-remote-config.js +1 -1
  113. package/dist/src/sdk-selectors.js +1 -1
  114. package/dist/src/selector-runtime.js +1 -1
  115. package/dist/src/selector-vocabulary.js +1 -0
  116. package/dist/src/selectors.d.ts +12 -3
  117. package/dist/src/selectors.js +1 -1
  118. package/dist/src/server.js +3 -3
  119. package/dist/src/session-target-evidence.js +1 -0
  120. package/dist/src/session.js +8 -10
  121. package/dist/src/simctl.js +1 -1
  122. package/dist/src/simulator.js +1 -1
  123. package/dist/src/snapshot-diagnostics.js +5 -2
  124. package/dist/src/snapshot-quality.js +1 -1
  125. package/dist/src/snapshot.js +1 -1
  126. package/dist/src/snapshot2.js +1 -2
  127. package/dist/src/tool-provider3.js +1 -0
  128. package/dist/src/unsupported-interactor.js +1 -0
  129. package/dist/src/url.js +1 -1
  130. package/dist/src/web.js +1 -1
  131. package/package.json +34 -27
  132. package/server.json +2 -2
  133. package/skills/agent-device/SKILL.md +5 -3
  134. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.apk +0 -0
  135. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.apk.sha256 +0 -1
  136. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.3.manifest.json +0 -10
  137. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.apk +0 -0
  138. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.3.apk.sha256 +0 -1
  139. package/dist/src/apple-runner-options.js +0 -1
  140. package/dist/src/cli-output.js +0 -1
  141. package/dist/src/client.js +0 -3
  142. package/dist/src/handler-utils.js +0 -1
  143. package/dist/src/output-path.js +0 -1
  144. package/dist/src/points.js +0 -4
  145. package/dist/src/provider.js +0 -1
  146. package/dist/src/providers.js +0 -1
  147. package/dist/src/record-trace-recording.js +0 -23
  148. package/dist/src/recording-provider.js +0 -1
  149. package/dist/src/sdk-batch.js +0 -2
  150. package/dist/src/selectors2.js +0 -1
  151. package/dist/src/ttl-memo.js +0 -1
  152. package/dist/src/tv-remote.js +0 -14
  153. package/dist/src/xml.js +0 -2
  154. /package/{macos-helper → apple/macos-helper}/Package.swift +0 -0
  155. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/AudioProbe.swift +0 -0
  156. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift +0 -0
  157. /package/{macos-helper → apple/macos-helper}/Sources/AgentDeviceMacOSHelper/main.swift +0 -0
  158. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +0 -0
  159. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +0 -0
  160. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -0
  161. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +0 -0
  162. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +0 -0
  163. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests.entitlements +0 -0
  164. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.h +0 -0
  165. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.m +0 -0
  166. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTapPointPolicy.swift +0 -0
  167. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +0 -0
  168. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +0 -0
  169. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +0 -0
  170. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +0 -0
  171. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +0 -0
  172. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +0 -0
  173. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +0 -0
  174. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +0 -0
  175. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +0 -0
  176. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +0 -0
  177. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -0
  178. /package/dist/{apple-runner → apple/runner}/AgentDeviceRunner/RecordingScripts/recording-trim.swift +0 -0
  179. /package/{src/platforms/linux → linux}/atspi-dump.py +0 -0
@@ -146,8 +146,8 @@ final class RunnerCommandJournal {
146
146
  case .tap, .mouseClick, .longPress, .drag,
147
147
  .remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
148
148
  .backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
149
- .alert, .pinch, .sequence, .rotateGesture, .transformGesture, .recordStart, .recordStop,
150
- .status, .uptime, .shutdown:
149
+ .alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
150
+ .status, .uptime, .activate, .terminate, .targetReset, .shutdown:
151
151
  return true
152
152
  }
153
153
  }
@@ -13,6 +13,16 @@ private enum RunnerInterfaceOrientation {
13
13
  }
14
14
 
15
15
  extension RunnerTests {
16
+ enum PlannedGestureExecution: Equatable {
17
+ case fastSwipe
18
+ case sampled
19
+ }
20
+
21
+ enum SynthesizedDragProfile {
22
+ case continuous
23
+ case fastSwipe
24
+ }
25
+
16
26
  struct TouchVisualizationFrame {
17
27
  let x: Double
18
28
  let y: Double
@@ -108,9 +118,9 @@ extension RunnerTests {
108
118
  #endif
109
119
  }
110
120
 
111
- func rotateDevice(to orientationName: String) -> Bool {
121
+ func rotateDevice(to orientationName: String) -> String? {
112
122
  #if os(macOS) || os(tvOS) || os(visionOS)
113
- return false
123
+ return nil
114
124
  #else
115
125
  switch orientationName {
116
126
  case "portrait":
@@ -122,10 +132,21 @@ extension RunnerTests {
122
132
  case "landscape-right":
123
133
  XCUIDevice.shared.orientation = .landscapeRight
124
134
  default:
125
- return false
135
+ return nil
126
136
  }
127
137
  sleepFor(0.2)
128
- return true
138
+ switch XCUIDevice.shared.orientation {
139
+ case .portrait:
140
+ return "portrait"
141
+ case .portraitUpsideDown:
142
+ return "portrait-upside-down"
143
+ case .landscapeLeft:
144
+ return "landscape-left"
145
+ case .landscapeRight:
146
+ return "landscape-right"
147
+ default:
148
+ return nil
149
+ }
129
150
  #endif
130
151
  }
131
152
 
@@ -139,7 +160,8 @@ extension RunnerTests {
139
160
  app: XCUIApplication,
140
161
  selectorKey: String,
141
162
  selectorValue: String,
142
- allowNonHittableFallback: Bool = false
163
+ allowNonHittableFallback: Bool = false,
164
+ expectedPoint: CGPoint? = nil
143
165
  ) -> SelectorElementMatch {
144
166
  let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
145
167
  guard !value.isEmpty else {
@@ -163,6 +185,9 @@ extension RunnerTests {
163
185
  var nonHittableElement: XCUIElement?
164
186
  let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
165
187
  for element in matches where element.exists {
188
+ if let expectedPoint, !element.frame.contains(expectedPoint) {
189
+ continue
190
+ }
166
191
  if !element.isHittable {
167
192
  if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
168
193
  guard nonHittableElement == nil else {
@@ -775,7 +800,7 @@ extension RunnerTests {
775
800
  x2: Double,
776
801
  y2: Double,
777
802
  durationMs: Double,
778
- semantics: SynthesizedDragSemantics?,
803
+ profile: SynthesizedDragProfile = .continuous,
779
804
  context: SynthesizedCoordinateContext? = nil
780
805
  ) -> RunnerInteractionOutcome {
781
806
  #if os(iOS)
@@ -795,8 +820,9 @@ extension RunnerTests {
795
820
  let frame = context.referenceFrame
796
821
  let start = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
797
822
  let end = nativeSynthesizedPoint(orientedX: x2, orientedY: y2, in: frame, interfaceOrientation: orientation)
798
- let message = if semantics == .swipe {
799
- RunnerSynthesizedGesture.synthesizeSwipe(
823
+ let message = switch profile {
824
+ case .continuous:
825
+ RunnerSynthesizedGesture.synthesizeContinuousDrag(
800
826
  withApplication: app,
801
827
  x: Double(start.x),
802
828
  y: Double(start.y),
@@ -804,8 +830,8 @@ extension RunnerTests {
804
830
  y2: Double(end.y),
805
831
  durationMs: durationMs
806
832
  )
807
- } else {
808
- RunnerSynthesizedGesture.synthesizeContinuousDrag(
833
+ case .fastSwipe:
834
+ RunnerSynthesizedGesture.synthesizeSwipe(
809
835
  withApplication: app,
810
836
  x: Double(start.x),
811
837
  y: Double(start.y),
@@ -1166,125 +1192,143 @@ extension RunnerTests {
1166
1192
  }
1167
1193
  }
1168
1194
 
1169
- func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1170
- #if os(iOS)
1171
- // A coordinate tap+drag is a single-finger gesture: React Native reads it as a pan
1172
- // and the pinch scale never changes (#629). Drive the two-finger XCTest synthesis
1173
- // path (the same one transformGesture uses) with zero translation/rotation so RN's
1174
- // pinch recognizer actually fires.
1175
- let frame = interactionRoot(app: app).frame
1176
- let centerX = x ?? Double(frame.midX)
1177
- let centerY = y ?? Double(frame.midY)
1178
- return transformGesture(
1179
- app: app,
1180
- x: centerX,
1181
- y: centerY,
1182
- dx: 0,
1183
- dy: 0,
1184
- scale: scale,
1185
- degrees: 0,
1186
- durationMs: 300
1187
- )
1188
- #elseif os(tvOS)
1189
- return .unsupported(
1190
- message: "pinch is not supported on tvOS",
1191
- hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
1192
- )
1193
- #else
1194
- return .unsupported(
1195
- message: "pinch is not supported on macOS",
1196
- hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
1197
- )
1198
- #endif
1195
+ func plannedGestureValidationError(_ plan: RunnerGesturePlan) -> String? {
1196
+ guard plan.topology == "single" || plan.topology == "two" else {
1197
+ return "planned gesture topology must be single or two"
1198
+ }
1199
+ let supportedIntent = plan.topology == "single"
1200
+ ? plan.intent == "fling" || plan.intent == "pan"
1201
+ : plan.intent == "pan" || plan.intent == "pinch" || plan.intent == "rotate"
1202
+ || plan.intent == "transform"
1203
+ guard supportedIntent else { return "planned gesture has unsupported intent for its topology" }
1204
+ if plan.topology == "single" {
1205
+ guard plan.executionProfile == "endpoint-hold" || plan.executionProfile == "timed-pan" else {
1206
+ return "single-pointer gesture requires a supported execution profile"
1207
+ }
1208
+ } else if plan.executionProfile != nil {
1209
+ return "multi-touch gesture cannot define a single-pointer execution profile"
1210
+ }
1211
+ guard plan.durationMs.isFinite, plan.durationMs >= 16, plan.durationMs <= 10_000 else {
1212
+ return "planned gesture durationMs must be between 16 and 10000"
1213
+ }
1214
+ let viewport = plan.viewport
1215
+ guard viewport.x.isFinite, viewport.y.isFinite, viewport.width.isFinite,
1216
+ viewport.height.isFinite, viewport.width > 0, viewport.height > 0
1217
+ else {
1218
+ return "planned gesture viewport must be finite and positive"
1219
+ }
1220
+ let expectedPointerCount = plan.topology == "single" ? 1 : 2
1221
+ guard plan.pointers.count == expectedPointerCount else {
1222
+ return "planned gesture pointer count does not match topology"
1223
+ }
1224
+ for (index, pointer) in plan.pointers.enumerated() where pointer.pointerId != index {
1225
+ return "planned gesture requires ordered pointer ids"
1226
+ }
1227
+ let firstSamples = plan.pointers[0].samples
1228
+ guard firstSamples.count >= 2 else { return "planned pointer paths require at least two samples" }
1229
+ for pointer in plan.pointers {
1230
+ guard pointer.samples.count == firstSamples.count else {
1231
+ return "planned pointer paths require matching samples"
1232
+ }
1233
+ var previousOffset = -1.0
1234
+ for (index, sample) in pointer.samples.enumerated() {
1235
+ guard sample.offsetMs.isFinite,
1236
+ sample.offsetMs == firstSamples[index].offsetMs,
1237
+ sample.offsetMs > previousOffset
1238
+ else {
1239
+ return "planned pointer sample offsets must match and strictly increase"
1240
+ }
1241
+ let point = sample.point
1242
+ guard point.x.isFinite, point.y.isFinite,
1243
+ point.x >= viewport.x,
1244
+ point.x <= viewport.x + viewport.width,
1245
+ point.y >= viewport.y,
1246
+ point.y <= viewport.y + viewport.height
1247
+ else {
1248
+ return "planned pointer sample lies outside the viewport"
1249
+ }
1250
+ previousOffset = sample.offsetMs
1251
+ }
1252
+ guard pointer.samples.first?.offsetMs == 0,
1253
+ pointer.samples.last?.offsetMs == plan.durationMs
1254
+ else {
1255
+ return "planned pointer paths must start at 0 and end at durationMs"
1256
+ }
1257
+ }
1258
+ if plan.topology == "two" {
1259
+ guard let firstStart = firstSamples.first?.point,
1260
+ let secondStart = plan.pointers[1].samples.first?.point,
1261
+ hypot(firstStart.x - secondStart.x, firstStart.y - secondStart.y) > 0
1262
+ else {
1263
+ return "planned pointer paths require a positive initial span"
1264
+ }
1265
+ }
1266
+ return nil
1199
1267
  }
1200
1268
 
1201
- func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
1202
- #if os(iOS)
1203
- // Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
1204
- // with zero translation/scale so React Native's rotation recognizer actually fires. The native
1205
- // XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
1206
- // handler does not read reliably — the same class of problem #629/#634 fixed for pinch.
1207
- // velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
1208
- // keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
1209
- let frame = interactionRoot(app: app).frame
1210
- let centerX = x ?? Double(frame.midX)
1211
- let centerY = y ?? Double(frame.midY)
1212
- return transformGesture(
1213
- app: app,
1214
- x: centerX,
1215
- y: centerY,
1216
- dx: 0,
1217
- dy: 0,
1218
- scale: 1,
1219
- degrees: degrees,
1220
- durationMs: 300
1221
- )
1222
- #elseif os(tvOS)
1223
- return .unsupported(
1224
- message: "rotate-gesture is not supported on tvOS",
1225
- hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
1226
- )
1227
- #else
1228
- return .unsupported(
1229
- message: "rotate-gesture is not supported on macOS",
1230
- hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
1231
- )
1232
- #endif
1269
+ func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
1270
+ plan.topology == "single" && plan.executionProfile == "endpoint-hold"
1271
+ ? .fastSwipe
1272
+ : .sampled
1233
1273
  }
1234
1274
 
1235
- func transformGesture(
1275
+ func sampledPlannedGesture(
1236
1276
  app: XCUIApplication,
1237
- x: Double,
1238
- y: Double,
1239
- dx: Double,
1240
- dy: Double,
1241
- scale: Double,
1242
- degrees: Double,
1243
- durationMs: Double
1277
+ plan: RunnerGesturePlan
1244
1278
  ) -> RunnerInteractionOutcome {
1245
1279
  #if os(iOS)
1246
- let target = interactionRoot(app: app)
1247
1280
  let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
1248
- let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: app.frame, interfaceOrientation: orientation)
1249
- let vector = nativeSynthesizedVector(orientedDx: dx, orientedDy: dy, interfaceOrientation: orientation)
1250
- if let message = RunnerSynthesizedGesture.synthesizeTransform(
1281
+ // The portable planner and validation use this exact viewport. Using app.frame here can
1282
+ // diverge when XCTest unions transformed/off-screen descendants into the application frame.
1283
+ let frame = CGRect(
1284
+ x: plan.viewport.x,
1285
+ y: plan.viewport.y,
1286
+ width: plan.viewport.width,
1287
+ height: plan.viewport.height
1288
+ )
1289
+ let pointerSamples: [[[String: NSNumber]]] = plan.pointers.map { pointer in
1290
+ pointer.samples.map { sample in
1291
+ let point = nativeSynthesizedPoint(
1292
+ orientedX: sample.point.x,
1293
+ orientedY: sample.point.y,
1294
+ in: frame,
1295
+ interfaceOrientation: orientation
1296
+ )
1297
+ return [
1298
+ "x": NSNumber(value: Double(point.x)),
1299
+ "y": NSNumber(value: Double(point.y)),
1300
+ "offsetMs": NSNumber(value: sample.offsetMs),
1301
+ ]
1302
+ }
1303
+ }
1304
+ if let message = RunnerSynthesizedGesture.synthesizeGesture(
1251
1305
  withApplication: app,
1252
- x: Double(point.x),
1253
- y: Double(point.y),
1254
- dx: Double(vector.dx),
1255
- dy: Double(vector.dy),
1256
- scale: scale,
1257
- degrees: degrees,
1258
- radius: transformGestureRadius(frame: target.frame, scale: scale),
1259
- durationMs: durationMs
1306
+ pointerSamples: pointerSamples
1260
1307
  ) {
1261
1308
  return .unsupported(
1262
1309
  message: message,
1263
- hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
1310
+ hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode if this persists."
1264
1311
  )
1265
1312
  }
1266
1313
  return .performed
1267
1314
  #elseif os(tvOS)
1268
1315
  return .unsupported(
1269
- message: "transformGesture is not supported on tvOS",
1270
- hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
1316
+ message: "two-finger gestures are not supported on tvOS",
1317
+ hint: "tvOS has no touch input; use remote-driven navigation."
1318
+ )
1319
+ #elseif os(visionOS)
1320
+ return .unsupported(
1321
+ message: "two-finger touch gestures are not supported on visionOS",
1322
+ hint: "The current XCTest synthesizer supports iOS and iPadOS touch simulators only."
1271
1323
  )
1272
1324
  #else
1273
1325
  return .unsupported(
1274
- message: "transformGesture is not supported on macOS",
1275
- hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
1326
+ message: "two-finger gestures are not supported on macOS",
1327
+ hint: "macOS automation has no multi-touch input; run on an iOS simulator."
1276
1328
  )
1277
1329
  #endif
1278
1330
  }
1279
1331
 
1280
- private func transformGestureRadius(frame: CGRect, scale: Double) -> Double {
1281
- let shorterSide = Double(min(frame.width, frame.height))
1282
- let frameRadius = shorterSide * 0.20
1283
- let minimumEndRadius = shorterSide * 0.08
1284
- let scaleAdjustedRadius = scale < 1.0 ? max(frameRadius, minimumEndRadius / scale) : frameRadius
1285
- return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
1286
- }
1287
-
1288
1332
  private func interactionRoot(app: XCUIApplication) -> XCUIElement {
1289
1333
  let windows = app.windows.allElementsBoundByIndex
1290
1334
  if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
@@ -85,6 +85,8 @@ extension RunnerTests {
85
85
  }
86
86
  currentApp = app
87
87
  currentBundleId = nil
88
+ currentAppProcessIdentifier = nil
89
+ snapshotXCTestPenaltyWarmupExemptionPending = false
88
90
  }
89
91
 
90
92
  func invalidateCachedTarget(reason: String) {
@@ -93,6 +95,48 @@ extension RunnerTests {
93
95
  }
94
96
  currentApp = nil
95
97
  currentBundleId = nil
98
+ currentAppProcessIdentifier = nil
99
+ snapshotXCTestPenaltyWarmupExemptionPending = false
100
+ }
101
+
102
+ func resetTargetAfterExternalRelaunch() -> Response {
103
+ invalidateCachedTarget(reason: "external_app_relaunch")
104
+ needsFirstInteractionDelay = true
105
+ return Response(ok: true, data: DataPayload(message: "target reset"))
106
+ }
107
+
108
+ func refreshCachedTargetIfProcessChanged(bundleId: String) {
109
+ guard currentBundleId == bundleId, currentApp != nil else { return }
110
+ let candidate = XCUIApplication(bundleIdentifier: bundleId)
111
+ let observedProcessIdentifier = Self.processIdentifier(of: candidate)
112
+ guard Self.shouldRefreshCachedTarget(
113
+ cachedProcessIdentifier: currentAppProcessIdentifier,
114
+ observedProcessIdentifier: observedProcessIdentifier
115
+ ) else { return }
116
+ NSLog(
117
+ "AGENT_DEVICE_RUNNER_TARGET_CACHE_REFRESH bundle=%@ previousPid=%d currentPid=%d",
118
+ bundleId,
119
+ currentAppProcessIdentifier ?? 0,
120
+ observedProcessIdentifier ?? 0
121
+ )
122
+ currentApp = candidate
123
+ currentAppProcessIdentifier = observedProcessIdentifier
124
+ clearSnapshotXCTestChannelPenalty(reason: "target_process_changed")
125
+ snapshotXCTestPenaltyWarmupExemptionPending = true
126
+ needsFirstInteractionDelay = true
127
+ }
128
+
129
+ static func processIdentifier(of target: XCUIApplication) -> Int? {
130
+ let value = RunnerAXSnapshotBridge.processIdentifier(for: target)
131
+ return value > 0 ? value : nil
132
+ }
133
+
134
+ static func shouldRefreshCachedTarget(
135
+ cachedProcessIdentifier: Int?,
136
+ observedProcessIdentifier: Int?
137
+ ) -> Bool {
138
+ guard let cachedProcessIdentifier, let observedProcessIdentifier else { return false }
139
+ return cachedProcessIdentifier != observedProcessIdentifier
96
140
  }
97
141
 
98
142
  func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
@@ -131,16 +175,26 @@ extension RunnerTests {
131
175
 
132
176
  func activateTarget(bundleId: String, reason: String) -> XCUIApplication {
133
177
  let target = XCUIApplication(bundleIdentifier: bundleId)
178
+ let initialState = target.state
134
179
  NSLog(
135
180
  "AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@",
136
181
  bundleId,
137
- target.state.rawValue,
182
+ initialState.rawValue,
138
183
  reason
139
184
  )
140
185
  // activate avoids terminating and relaunching the target app
141
- target.activate()
186
+ if initialState == .runningForeground {
187
+ NSLog(
188
+ "AGENT_DEVICE_RUNNER_ACTIVATE_SKIPPED bundle=%@ reason=already_foreground",
189
+ bundleId
190
+ )
191
+ } else {
192
+ target.activate()
193
+ }
142
194
  currentApp = target
143
195
  currentBundleId = bundleId
196
+ currentAppProcessIdentifier = Self.processIdentifier(of: target)
197
+ snapshotXCTestPenaltyWarmupExemptionPending = false
144
198
  needsFirstInteractionDelay = true
145
199
  return target
146
200
  }
@@ -0,0 +1,73 @@
1
+ import XCTest
2
+
3
+
4
+ extension RunnerTests {
5
+
6
+ func testCachedTargetRefreshRequiresChangedPositiveProcessIdentity() {
7
+ XCTAssertFalse(
8
+ Self.shouldRefreshCachedTarget(
9
+ cachedProcessIdentifier: nil,
10
+ observedProcessIdentifier: 42
11
+ )
12
+ )
13
+ XCTAssertFalse(
14
+ Self.shouldRefreshCachedTarget(
15
+ cachedProcessIdentifier: 42,
16
+ observedProcessIdentifier: 42
17
+ )
18
+ )
19
+ XCTAssertTrue(
20
+ Self.shouldRefreshCachedTarget(
21
+ cachedProcessIdentifier: 41,
22
+ observedProcessIdentifier: 42
23
+ )
24
+ )
25
+ }
26
+
27
+ func testSnapshotPenaltyWarmupExemptionIsConsumedOnce() {
28
+ snapshotXCTestPenaltyWarmupExemptionPending = true
29
+
30
+ XCTAssertTrue(consumeSnapshotXCTestPenaltyWarmupExemption())
31
+ XCTAssertFalse(consumeSnapshotXCTestPenaltyWarmupExemption())
32
+ }
33
+
34
+ func testSnapshotPenaltyCanBeClearedAcrossTargetProcessReplacement() {
35
+ penalizeSnapshotXCTestChannel(bundleId: "com.example.app", reason: "test")
36
+ XCTAssertTrue(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
37
+
38
+ clearSnapshotXCTestChannelPenalty(reason: "target_process_changed")
39
+
40
+ XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
41
+ }
42
+
43
+ func testCachedTargetInvalidationClearsProcessBoundState() {
44
+ currentApp = app
45
+ currentBundleId = "com.example.app"
46
+ currentAppProcessIdentifier = 42
47
+ snapshotXCTestPenaltyWarmupExemptionPending = true
48
+
49
+ invalidateCachedTarget(reason: "unit_test")
50
+
51
+ XCTAssertNil(currentApp)
52
+ XCTAssertNil(currentBundleId)
53
+ XCTAssertNil(currentAppProcessIdentifier)
54
+ XCTAssertFalse(snapshotXCTestPenaltyWarmupExemptionPending)
55
+ }
56
+
57
+ func testTargetResetInvalidatesProcessBoundStateWithoutRestartingRunner() {
58
+ currentApp = app
59
+ currentBundleId = "com.example.app"
60
+ currentAppProcessIdentifier = 42
61
+ snapshotXCTestPenaltyWarmupExemptionPending = true
62
+ needsFirstInteractionDelay = false
63
+
64
+ let response = resetTargetAfterExternalRelaunch()
65
+
66
+ XCTAssertTrue(response.ok)
67
+ XCTAssertNil(currentApp)
68
+ XCTAssertNil(currentBundleId)
69
+ XCTAssertNil(currentAppProcessIdentifier)
70
+ XCTAssertFalse(snapshotXCTestPenaltyWarmupExemptionPending)
71
+ XCTAssertTrue(needsFirstInteractionDelay)
72
+ }
73
+ }