agent-device 0.18.3 → 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 (219) hide show
  1. package/README.md +5 -1
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.18.3.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.18.3.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.18.3.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.18.3.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
  8. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTapPointPolicy.swift +28 -0
  9. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +33 -127
  10. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +630 -177
  11. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +155 -0
  12. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +67 -0
  13. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +173 -122
  14. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
  15. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +64 -0
  16. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -182
  17. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +92 -51
  18. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +213 -137
  19. package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
  20. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +208 -46
  21. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +82 -11
  22. package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +74 -0
  23. package/dist/src/agent-browser-tool.js +1 -0
  24. package/dist/src/android-adb.d.ts +63 -234
  25. package/dist/src/android-adb.js +1 -1
  26. package/dist/src/android-input-ownership.js +6 -0
  27. package/dist/src/android.js +1 -5
  28. package/dist/src/app-lifecycle.js +7 -0
  29. package/dist/src/apple-runner-options.js +1 -0
  30. package/dist/src/apps.js +5 -11
  31. package/dist/src/artifacts.d.ts +4 -3
  32. package/dist/src/artifacts.js +1 -1
  33. package/dist/src/audio-probe.js +1 -1
  34. package/dist/src/batch.d.ts +2 -177
  35. package/dist/src/batch.js +1 -1
  36. package/dist/src/cli-help.js +178 -60
  37. package/dist/src/cli-output.js +1 -1
  38. package/dist/src/cli-runner.js +2 -0
  39. package/dist/src/cli.js +12 -41
  40. package/dist/src/client-metro-companion.js +1 -0
  41. package/dist/src/client.js +3 -0
  42. package/dist/src/command-metadata.js +1 -0
  43. package/dist/src/command-schema.js +22 -0
  44. package/dist/src/command-surface.js +1 -1
  45. package/dist/src/contracts.d.ts +2 -213
  46. package/dist/src/contracts.js +1 -1
  47. package/dist/src/debug-symbols.js +5 -3
  48. package/dist/src/device-input-state.js +2 -0
  49. package/dist/src/devices.js +1 -1
  50. package/dist/src/devices2.js +1 -0
  51. package/dist/src/devices3.js +1 -0
  52. package/dist/src/find.js +1 -1
  53. package/dist/src/finders.d.ts +2 -81
  54. package/dist/src/finders.js +1 -1
  55. package/dist/src/generic.js +1 -1
  56. package/dist/src/handler-utils.js +1 -1
  57. package/dist/src/helper.js +2 -0
  58. package/dist/src/host-process.js +2 -0
  59. package/dist/src/index.d.ts +1444 -1786
  60. package/dist/src/index.js +1 -1
  61. package/dist/src/input-actions.js +3 -1
  62. package/dist/src/input-actions2.js +1 -0
  63. package/dist/src/install-source.d.ts +13 -14
  64. package/dist/src/install-source.js +1 -1
  65. package/dist/src/install-source2.js +1 -0
  66. package/dist/src/interaction-snapshot.js +1 -0
  67. package/dist/src/interaction.js +1 -1
  68. package/dist/src/interactor.js +2 -1
  69. package/dist/src/internal/bin.d.ts +1 -1
  70. package/dist/src/internal/bin.js +2 -5
  71. package/dist/src/internal/companion-tunnel.d.ts +1 -1
  72. package/dist/src/internal/companion-tunnel.js +1 -1
  73. package/dist/src/internal/daemon.d.ts +1 -1
  74. package/dist/src/internal/daemon.js +18 -1
  75. package/dist/src/internal/png-worker.d.ts +29 -26
  76. package/dist/src/internal/png-worker.js +1 -1
  77. package/dist/src/internal/update-check-entry.d.ts +1 -1
  78. package/dist/src/internal/update-check-entry.js +1 -1
  79. package/dist/src/io.d.ts +2 -85
  80. package/dist/src/io.js +1 -1
  81. package/dist/src/lease.js +1 -1
  82. package/dist/src/linux.js +1 -1
  83. package/dist/src/manifest.js +1 -0
  84. package/dist/src/metro.d.ts +69 -113
  85. package/dist/src/metro.js +1 -1
  86. package/dist/src/metro2.js +1 -0
  87. package/dist/src/notifications.js +1 -1
  88. package/dist/src/output-path.js +1 -0
  89. package/dist/src/path-resolution.js +1 -0
  90. package/dist/src/png-worker-contract.js +1 -0
  91. package/dist/src/points.js +4 -0
  92. package/dist/src/project-runtime.js +1 -0
  93. package/dist/src/provider.js +1 -1
  94. package/dist/src/providers.js +1 -0
  95. package/dist/src/react-native.js +1 -1
  96. package/dist/src/record-trace-recording.js +2 -5
  97. package/dist/src/record-trace.js +1 -1
  98. package/dist/src/recording-provider.js +1 -1
  99. package/dist/src/registry.js +38 -0
  100. package/dist/src/remote-config.d.ts +2 -83
  101. package/dist/src/reporting.js +9 -0
  102. package/dist/src/rolldown-runtime.js +1 -0
  103. package/dist/src/runner-client.js +1 -0
  104. package/dist/src/runner-provider.js +1 -0
  105. package/dist/src/runner-sequence.js +1 -1
  106. package/dist/src/runtime-transport.js +1 -0
  107. package/dist/src/runtime.js +3 -0
  108. package/dist/src/scoped-provider.js +1 -0
  109. package/dist/src/sdk-android-adb.d.ts +144 -0
  110. package/dist/src/sdk-android-adb.js +4 -0
  111. package/dist/src/sdk-app-inventory.d.ts +4 -0
  112. package/dist/src/sdk-app-inventory.js +1 -0
  113. package/dist/src/sdk-batch-runner.d.ts +39 -0
  114. package/dist/src/sdk-batch-runner.js +2 -0
  115. package/dist/src/sdk-batch.js +2 -0
  116. package/dist/src/sdk-contracts.d.ts +386 -0
  117. package/dist/src/sdk-contracts.js +2 -0
  118. package/dist/src/sdk-finders.d.ts +60 -0
  119. package/dist/src/sdk-finders.js +1 -0
  120. package/dist/src/sdk-io.d.ts +78 -0
  121. package/dist/src/sdk-io.js +1 -0
  122. package/dist/src/sdk-remote-config.d.ts +139 -0
  123. package/dist/src/sdk-remote-config.js +1 -0
  124. package/dist/src/sdk-selectors.js +1 -0
  125. package/dist/src/selector-runtime.js +1 -1
  126. package/dist/src/selectors.d.ts +38 -137
  127. package/dist/src/selectors.js +1 -1
  128. package/dist/src/selectors2.js +1 -0
  129. package/dist/src/server.js +3 -2
  130. package/dist/src/session.js +10 -11
  131. package/dist/src/setting-state.js +1 -0
  132. package/dist/src/simctl.js +1 -0
  133. package/dist/src/simulator.js +1 -2
  134. package/dist/src/snapshot-diagnostics.js +3 -0
  135. package/dist/src/snapshot-helper.js +5 -0
  136. package/dist/src/snapshot-quality.js +1 -0
  137. package/dist/src/snapshot.js +1 -2
  138. package/dist/src/snapshot2.js +2 -0
  139. package/dist/src/{5560.js → timeouts.js} +1 -1
  140. package/dist/src/tool-provider.js +3 -0
  141. package/dist/src/tool-provider2.js +1 -0
  142. package/dist/src/ttl-memo.js +1 -0
  143. package/dist/src/tv-remote.js +14 -0
  144. package/dist/src/update-check.js +1 -0
  145. package/dist/src/url.js +1 -0
  146. package/dist/src/version.js +1 -0
  147. package/dist/src/web.js +1 -1
  148. package/dist/src/xml.js +2 -0
  149. package/package.json +13 -19
  150. package/server.json +2 -2
  151. package/skills/agent-device/SKILL.md +8 -7
  152. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.18.3.apk.sha256 +0 -1
  153. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.18.3.apk.sha256 +0 -1
  154. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +0 -443
  155. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +0 -169
  156. package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +0 -220
  157. package/dist/src/1404.js +0 -1
  158. package/dist/src/1986.js +0 -1
  159. package/dist/src/2748.js +0 -1
  160. package/dist/src/2948.js +0 -30
  161. package/dist/src/3006.js +0 -1
  162. package/dist/src/314.js +0 -1
  163. package/dist/src/3340.js +0 -8
  164. package/dist/src/4418.js +0 -1
  165. package/dist/src/4428.js +0 -3
  166. package/dist/src/485.js +0 -1
  167. package/dist/src/4917.js +0 -1
  168. package/dist/src/495.js +0 -5
  169. package/dist/src/5448.js +0 -3
  170. package/dist/src/5484.js +0 -1
  171. package/dist/src/5643.js +0 -1
  172. package/dist/src/587.js +0 -1
  173. package/dist/src/5913.js +0 -4
  174. package/dist/src/6849.js +0 -1
  175. package/dist/src/6918.js +0 -1
  176. package/dist/src/6967.js +0 -1
  177. package/dist/src/760.js +0 -1
  178. package/dist/src/7871.js +0 -1
  179. package/dist/src/8184.js +0 -1
  180. package/dist/src/8225.js +0 -1
  181. package/dist/src/8391.js +0 -1
  182. package/dist/src/8404.js +0 -2
  183. package/dist/src/8414.js +0 -2
  184. package/dist/src/8875.js +0 -4
  185. package/dist/src/893.js +0 -1
  186. package/dist/src/8936.js +0 -1
  187. package/dist/src/9414.js +0 -1
  188. package/dist/src/9581.js +0 -22
  189. package/dist/src/9612.js +0 -1
  190. package/dist/src/9624.js +0 -1
  191. package/dist/src/9722.js +0 -39
  192. package/dist/src/devices~1.js +0 -1
  193. package/dist/src/devices~2.js +0 -1
  194. package/dist/src/input-actions~1.js +0 -1
  195. package/dist/src/rslib-runtime.js +0 -1
  196. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +0 -0
  197. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +0 -0
  198. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -0
  199. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +0 -0
  200. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +0 -0
  201. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests.entitlements +0 -0
  202. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +0 -0
  203. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +0 -0
  204. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.h +0 -0
  205. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.m +0 -0
  206. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +0 -0
  207. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +0 -0
  208. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +0 -0
  209. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +0 -0
  210. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +0 -0
  211. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +0 -0
  212. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +0 -0
  213. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +0 -0
  214. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +0 -0
  215. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +0 -0
  216. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +0 -0
  217. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +0 -0
  218. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -0
  219. /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-trim.swift +0 -0
@@ -0,0 +1,142 @@
1
+ import XCTest
2
+
3
+ // Runner-local policy for AX-free/private synthesized iOS gestures.
4
+ //
5
+ // This policy is intentionally separate from the TS interaction guarantee matrix:
6
+ // ADR 0011 models element-targeting guarantees, while this module models command
7
+ // paths that must keep scroll/drag/sequence usable when XCTest AX is unhealthy.
8
+
9
+ enum RunnerAccessibilityHealth: String, Equatable {
10
+ case unknown
11
+ case healthy
12
+ case unavailable
13
+ }
14
+
15
+ enum SynthesizedKeyboardPolicy: String, Equatable, Hashable {
16
+ case never
17
+ case whenAccessibilityHealthy
18
+ case requiredWhenAvailable
19
+
20
+ func allowsProbe(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
21
+ switch self {
22
+ case .never:
23
+ return false
24
+ case .whenAccessibilityHealthy:
25
+ return accessibilityHealth == .healthy
26
+ case .requiredWhenAvailable:
27
+ return accessibilityHealth != .unavailable
28
+ }
29
+ }
30
+ }
31
+
32
+ enum SynthesizedFallbackPolicy: String, Equatable, Hashable {
33
+ case privateSynthesisRequired
34
+ case xctestCoordinateWhenAccessibilityAvailable
35
+ case xctestCoordinateAllowed
36
+
37
+ func allowsXCTestCoordinateFallback(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
38
+ switch self {
39
+ case .privateSynthesisRequired:
40
+ return false
41
+ case .xctestCoordinateWhenAccessibilityAvailable:
42
+ return accessibilityHealth != .unavailable
43
+ case .xctestCoordinateAllowed:
44
+ return true
45
+ }
46
+ }
47
+ }
48
+
49
+ enum SynthesizedGesturePolicyKind: String, Equatable, Hashable {
50
+ case coordinateTap
51
+ case scroll
52
+ case synthesizedDrag
53
+ }
54
+
55
+ struct SynthesizedGesturePolicy: Equatable, Hashable {
56
+ let keyboardPolicy: SynthesizedKeyboardPolicy
57
+ let fallbackPolicy: SynthesizedFallbackPolicy
58
+ }
59
+
60
+ struct SynthesizedCoordinateContext {
61
+ let referenceFrame: CGRect
62
+ let keyboardPolicy: SynthesizedKeyboardPolicy
63
+ let fallbackPolicy: SynthesizedFallbackPolicy
64
+ let accessibilityHealth: RunnerAccessibilityHealth
65
+
66
+ func withReferenceFrame(_ frame: CGRect) -> SynthesizedCoordinateContext {
67
+ SynthesizedCoordinateContext(
68
+ referenceFrame: frame,
69
+ keyboardPolicy: keyboardPolicy,
70
+ fallbackPolicy: fallbackPolicy,
71
+ accessibilityHealth: accessibilityHealth
72
+ )
73
+ }
74
+
75
+ var allowsXCTestCoordinateFallback: Bool {
76
+ fallbackPolicy.allowsXCTestCoordinateFallback(accessibilityHealth: accessibilityHealth)
77
+ }
78
+
79
+ var allowsKeyboardProbe: Bool {
80
+ keyboardPolicy.allowsProbe(accessibilityHealth: accessibilityHealth)
81
+ }
82
+ }
83
+
84
+ func synthesizedGesturePolicy(_ kind: SynthesizedGesturePolicyKind) -> SynthesizedGesturePolicy {
85
+ switch kind {
86
+ case .coordinateTap:
87
+ return SynthesizedGesturePolicy(
88
+ keyboardPolicy: .never,
89
+ fallbackPolicy: .xctestCoordinateAllowed
90
+ )
91
+ case .scroll:
92
+ return SynthesizedGesturePolicy(
93
+ keyboardPolicy: .whenAccessibilityHealthy,
94
+ fallbackPolicy: .privateSynthesisRequired
95
+ )
96
+ case .synthesizedDrag:
97
+ return SynthesizedGesturePolicy(
98
+ keyboardPolicy: .requiredWhenAvailable,
99
+ fallbackPolicy: .xctestCoordinateWhenAccessibilityAvailable
100
+ )
101
+ }
102
+ }
103
+
104
+ func sequenceHasSynthesizedCoordinateStep(_ steps: [SequenceStep]) -> Bool {
105
+ steps.contains { step in
106
+ step.synthesized == true && (step.kind == "tap" || step.kind == "drag")
107
+ }
108
+ }
109
+
110
+ extension RunnerTests {
111
+ func synthesizedSequenceCoordinateContext(steps: [SequenceStep]) -> SynthesizedCoordinateContext? {
112
+ guard sequenceHasSynthesizedCoordinateStep(steps) else { return nil }
113
+ return synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
114
+ }
115
+
116
+ func logSynthesizedGesturePolicyDecision(
117
+ kind: SynthesizedGesturePolicyKind,
118
+ context: SynthesizedCoordinateContext?,
119
+ fallbackAttempted: Bool
120
+ ) {
121
+ #if os(iOS)
122
+ guard let context else {
123
+ NSLog(
124
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ context=unavailable fallbackAttempted=%@",
125
+ kind.rawValue,
126
+ fallbackAttempted.description
127
+ )
128
+ return
129
+ }
130
+ NSLog(
131
+ "AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ axHealth=%@ frameSource=screenshot keyboardPolicy=%@ fallbackPolicy=%@ fallbackAllowed=%@ fallbackAttempted=%@",
132
+ kind.rawValue,
133
+ context.accessibilityHealth.rawValue,
134
+ context.keyboardPolicy.rawValue,
135
+ context.fallbackPolicy.rawValue,
136
+ context.allowsXCTestCoordinateFallback.description,
137
+ fallbackAttempted.description
138
+ )
139
+ #endif
140
+ }
141
+ }
142
+
@@ -45,6 +45,11 @@ extension RunnerTests {
45
45
  }
46
46
  }
47
47
 
48
+ struct TextEntryStabilization {
49
+ let element: XCUIElement?
50
+ let focusConfirmed: Bool
51
+ }
52
+
48
53
  func clearTextInput(_ element: XCUIElement) {
49
54
  // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
50
55
  // field is empty. Why skip: the edge-tap computes a point from the element frame, which can
@@ -90,30 +95,45 @@ extension RunnerTests {
90
95
  #endif
91
96
  }
92
97
 
93
- func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
98
+ func stabilizeTextInputBeforeTyping(
99
+ app: XCUIApplication,
100
+ target: XCUIElement?,
101
+ keyboardVisibleBeforeTap: Bool? = nil
102
+ ) -> TextEntryStabilization {
94
103
  #if os(tvOS)
95
- return target
104
+ return TextEntryStabilization(element: target, focusConfirmed: true)
96
105
  #else
97
106
  let latest = target
98
- let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
107
+ let keyboardVisibleAtEntry = keyboardVisibleBeforeTap ?? isKeyboardVisible(app: app)
99
108
  let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
100
109
  while Date() < deadline {
101
110
  if let focused = focusedTextInput(app: app) {
102
- return focused
111
+ return TextEntryStabilization(element: focused, focusConfirmed: true)
103
112
  }
104
113
  // focusedTextInput is intentionally nil on iOS; treat the keyboard transitioning to
105
114
  // visible after our tap as the focus-moved signal. Don't fast-path when it was already up.
106
115
  if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
107
- return latest
116
+ return TextEntryStabilization(element: latest, focusConfirmed: true)
108
117
  }
109
118
  sleepFor(TextEntryTiming.pollInterval)
110
119
  }
111
- return latest
120
+ return TextEntryStabilization(element: latest, focusConfirmed: false)
112
121
  #endif
113
122
  }
114
123
 
115
124
  func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
116
125
  guard let x, let y else {
126
+ // Bare `type` targets the current first responder. On iOS we intentionally do not trust
127
+ // `hasKeyboardFocus`, but an already-visible software keyboard is sufficient evidence that
128
+ // app.typeText has a receiver; waiting the full readiness timeout cannot prove a stronger
129
+ // target because there is no selector/coordinate focus move to validate.
130
+ if isKeyboardVisible(app: app) {
131
+ return TextEntryTarget(
132
+ element: focusedTextInput(app: app),
133
+ refreshPoint: nil,
134
+ prefersFocusedElement: true
135
+ )
136
+ }
117
137
  let focused = waitForTextEntryReadiness(
118
138
  app: app,
119
139
  target: TextEntryTarget(
@@ -125,6 +145,7 @@ extension RunnerTests {
125
145
  return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
126
146
  }
127
147
 
148
+ let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
128
149
  let target = textInputAt(app: app, x: x, y: y)
129
150
  let requestedPoint = CGPoint(x: x, y: y)
130
151
  if let target {
@@ -137,15 +158,30 @@ extension RunnerTests {
137
158
  } else {
138
159
  _ = tapAt(app: app, x: x, y: y)
139
160
  }
140
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
141
- let element = waitForTextEntryReadiness(
142
- app: app,
143
- target: TextEntryTarget(
144
- element: stabilized ?? target,
145
- refreshPoint: requestedPoint,
161
+ // A visible keyboard is not enough evidence for app.typeText, because focus may still
162
+ // belong to a previous field. With a concrete target we type through XCUIElement.typeText,
163
+ // so after tapping it the iOS readiness timeout cannot prove anything stronger.
164
+ if keyboardVisibleBeforeTap, let target {
165
+ return TextEntryTarget(
166
+ element: target,
167
+ refreshPoint: textEntryRefreshPoint(for: target) ?? requestedPoint,
146
168
  prefersFocusedElement: false
147
169
  )
148
- ) ?? stabilized ?? target
170
+ }
171
+ let stabilized = stabilizeTextInputBeforeTyping(
172
+ app: app,
173
+ target: target,
174
+ keyboardVisibleBeforeTap: keyboardVisibleBeforeTap
175
+ )
176
+ let readyTarget = TextEntryTarget(
177
+ element: stabilized.element ?? target,
178
+ refreshPoint: requestedPoint,
179
+ prefersFocusedElement: false
180
+ )
181
+ let concreteTargetReady = keyboardVisibleBeforeTap && readyTarget.element != nil
182
+ let element = stabilized.focusConfirmed || concreteTargetReady
183
+ ? (stabilized.element ?? target)
184
+ : (waitForTextEntryReadiness(app: app, target: readyTarget) ?? stabilized.element ?? target)
149
185
  return TextEntryTarget(
150
186
  element: element,
151
187
  refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
@@ -155,18 +191,32 @@ extension RunnerTests {
155
191
 
156
192
  func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
157
193
  let point = textEntryRefreshPoint(for: element)
194
+ let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
158
195
  if let point {
159
196
  _ = tapAt(app: app, x: point.x, y: point.y)
160
197
  }
161
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
162
- let resolved = waitForTextEntryReadiness(
163
- app: app,
164
- target: TextEntryTarget(
165
- element: stabilized ?? element,
166
- refreshPoint: point,
198
+ // See the coordinate-target path above: direct element typing keeps this scoped to the
199
+ // tapped target, while the first-character warmup and final verify still catch dropped input.
200
+ if keyboardVisibleBeforeTap {
201
+ return TextEntryTarget(
202
+ element: element,
203
+ refreshPoint: textEntryRefreshPoint(for: element) ?? point,
167
204
  prefersFocusedElement: false
168
205
  )
169
- ) ?? stabilized ?? element
206
+ }
207
+ let stabilized = stabilizeTextInputBeforeTyping(
208
+ app: app,
209
+ target: element,
210
+ keyboardVisibleBeforeTap: keyboardVisibleBeforeTap
211
+ )
212
+ let readyTarget = TextEntryTarget(
213
+ element: stabilized.element ?? element,
214
+ refreshPoint: point,
215
+ prefersFocusedElement: false
216
+ )
217
+ let resolved = stabilized.focusConfirmed
218
+ ? (stabilized.element ?? element)
219
+ : (waitForTextEntryReadiness(app: app, target: readyTarget) ?? stabilized.element ?? element)
170
220
  return TextEntryTarget(
171
221
  element: resolved,
172
222
  refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
@@ -199,31 +249,51 @@ extension RunnerTests {
199
249
  target: TextEntryTarget,
200
250
  text: String,
201
251
  delaySeconds: Double,
202
- repairMode: TextTypingRepairMode = .none
252
+ repairMode: TextTypingRepairMode = .none,
253
+ commandId: String? = nil
203
254
  ) -> TextEntryResult {
255
+ let totalStartedAt = Date()
204
256
  guard !text.isEmpty else {
257
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: 0, mode: repairMode)
205
258
  return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
206
259
  }
207
260
  var activeTarget = target
261
+ let initialResolveStartedAt = Date()
208
262
  let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
209
263
  activeTarget = activeTarget.withElement(initialTarget)
210
264
  let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
211
265
  let initialText = repairMode == .append ? currentText : nil
212
266
  let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
267
+ logTextEntryPhase(
268
+ commandId: commandId,
269
+ phase: "initial-resolve",
270
+ startedAt: initialResolveStartedAt,
271
+ chars: text.count,
272
+ mode: repairMode
273
+ )
213
274
 
214
275
  if repairMode == .replacement {
215
276
  guard let replacementTarget = initialTarget else {
277
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
216
278
  return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
217
279
  }
218
280
  if currentText == nil || currentText?.isEmpty == false {
281
+ let clearStartedAt = Date()
219
282
  clearTextInput(replacementTarget)
220
283
  activeTarget = activeTarget.withElement(replacementTarget)
284
+ logTextEntryPhase(
285
+ commandId: commandId,
286
+ phase: "clear",
287
+ startedAt: clearStartedAt,
288
+ chars: text.count,
289
+ mode: repairMode
290
+ )
221
291
  }
222
292
  }
223
293
 
224
294
  func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
225
295
  if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
226
- app.typeText(value)
296
+ currentTarget.typeText(value)
227
297
  return currentTarget
228
298
  } else {
229
299
  app.typeText(value)
@@ -248,76 +318,158 @@ extension RunnerTests {
248
318
  let characters = Array(text)
249
319
  if delaySeconds > 0 && characters.count > 1 {
250
320
  var typedTarget: XCUIElement?
321
+ let delayedTypeStartedAt = Date()
251
322
  for (index, character) in characters.enumerated() {
252
323
  typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
253
324
  if index + 1 < characters.count {
254
325
  sleepFor(delaySeconds)
255
326
  }
256
327
  }
328
+ logTextEntryPhase(
329
+ commandId: commandId,
330
+ phase: "type-delayed",
331
+ startedAt: delayedTypeStartedAt,
332
+ chars: characters.count,
333
+ mode: repairMode
334
+ )
257
335
  if repairMode == .none {
336
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
258
337
  return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
259
338
  }
260
- let repairResult = repairTextEntryIfNeeded(
339
+ let verifyStartedAt = Date()
340
+ let result = verifyTextEntryWithRepairIfNeeded(
261
341
  app: app,
262
342
  target: activeTarget.withElement(typedTarget),
263
343
  expectedText: expectedText,
264
344
  repairMode: repairMode
265
345
  )
266
- return verifyTextEntry(
267
- app: app,
268
- target: activeTarget.withElement(typedTarget),
269
- expectedText: expectedText,
270
- repaired: repairResult.repaired
346
+ logTextEntryPhase(
347
+ commandId: commandId,
348
+ phase: "verify",
349
+ startedAt: verifyStartedAt,
350
+ chars: characters.count,
351
+ mode: repairMode
271
352
  )
353
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
354
+ return result
272
355
  }
273
356
 
274
357
  let typedTarget: XCUIElement?
275
358
  if repairMode != .none && characters.count > 1 {
276
359
  let firstCharacter = String(characters[0])
360
+ let firstStartedAt = Date()
277
361
  var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
362
+ logTextEntryPhase(
363
+ commandId: commandId,
364
+ phase: "type-first",
365
+ startedAt: firstStartedAt,
366
+ chars: 1,
367
+ mode: repairMode
368
+ )
278
369
  activeTarget = activeTarget.withElement(firstTypedTarget)
279
370
  let warmupExpectedText = expectedTextEntryValue(
280
371
  typedText: firstCharacter,
281
372
  mode: repairMode,
282
373
  initialText: initialText
283
374
  )
375
+ let warmupStartedAt = Date()
284
376
  waitForWarmupValue(warmupExpectedText, target: activeTarget)
377
+ logTextEntryPhase(
378
+ commandId: commandId,
379
+ phase: "warmup",
380
+ startedAt: warmupStartedAt,
381
+ chars: 1,
382
+ mode: repairMode
383
+ )
285
384
  let remainingText = String(characters.dropFirst())
385
+ let remainingStartedAt = Date()
286
386
  firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
387
+ logTextEntryPhase(
388
+ commandId: commandId,
389
+ phase: "type-remaining",
390
+ startedAt: remainingStartedAt,
391
+ chars: characters.count - 1,
392
+ mode: repairMode
393
+ )
287
394
  typedTarget = firstTypedTarget
288
395
  } else {
396
+ let typeStartedAt = Date()
289
397
  typedTarget = typeIntoCurrentTarget(text)
398
+ logTextEntryPhase(
399
+ commandId: commandId,
400
+ phase: "type-all",
401
+ startedAt: typeStartedAt,
402
+ chars: characters.count,
403
+ mode: repairMode
404
+ )
290
405
  }
291
406
  if repairMode == .none {
407
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
292
408
  return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
293
409
  }
294
- let repairResult = repairTextEntryIfNeeded(
410
+ let verifyStartedAt = Date()
411
+ let result = verifyTextEntryWithRepairIfNeeded(
295
412
  app: app,
296
413
  target: activeTarget.withElement(typedTarget),
297
414
  expectedText: expectedText,
298
415
  repairMode: repairMode
299
416
  )
300
- return verifyTextEntry(
301
- app: app,
302
- target: activeTarget.withElement(typedTarget),
303
- expectedText: expectedText,
304
- repaired: repairResult.repaired
417
+ logTextEntryPhase(
418
+ commandId: commandId,
419
+ phase: "verify",
420
+ startedAt: verifyStartedAt,
421
+ chars: characters.count,
422
+ mode: repairMode
305
423
  )
424
+ logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
425
+ return result
426
+ }
427
+
428
+ private func logTextEntryPhase(
429
+ commandId: String?,
430
+ phase: String,
431
+ startedAt: Date,
432
+ chars: Int,
433
+ mode: TextTypingRepairMode
434
+ ) {
435
+ NSLog(
436
+ "AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=%@ durationMs=%.1f chars=%d mode=%@",
437
+ commandId ?? "",
438
+ phase,
439
+ Date().timeIntervalSince(startedAt) * 1000.0,
440
+ chars,
441
+ textEntryModeName(mode)
442
+ )
443
+ }
444
+
445
+ func textEntryModeName(_ mode: TextTypingRepairMode) -> String {
446
+ switch mode {
447
+ case .none:
448
+ return "none"
449
+ case .append:
450
+ return "append"
451
+ case .replacement:
452
+ return "replacement"
453
+ }
306
454
  }
307
455
 
308
- private func repairTextEntryIfNeeded(
456
+ private func verifyTextEntryWithRepairIfNeeded(
309
457
  app: XCUIApplication,
310
458
  target: TextEntryTarget,
311
459
  expectedText: String?,
312
460
  repairMode: TextTypingRepairMode
313
461
  ) -> TextEntryResult {
462
+ let initialResult = verifyTextEntry(
463
+ app: app,
464
+ target: target,
465
+ expectedText: expectedText,
466
+ repaired: false
467
+ )
314
468
  #if os(iOS)
315
- guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
316
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
317
- }
318
- guard let expectedText else {
319
- let observedText = editableTextValue(for: targetElement)
320
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
469
+ guard initialResult.verified == false,
470
+ let expectedText = initialResult.expectedText
471
+ else {
472
+ return initialResult
321
473
  }
322
474
  guard shouldRepairTextEntry(
323
475
  app: app,
@@ -325,11 +477,16 @@ extension RunnerTests {
325
477
  expectedText: expectedText,
326
478
  repairMode: repairMode
327
479
  ) else {
328
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
480
+ return verifyTextEntry(
481
+ app: app,
482
+ target: target,
483
+ expectedText: expectedText,
484
+ repaired: false
485
+ )
329
486
  }
330
487
 
331
488
  guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
332
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
489
+ return initialResult
333
490
  }
334
491
  let observedText = editableTextValue(for: repairTarget) ?? ""
335
492
  NSLog(
@@ -338,10 +495,15 @@ extension RunnerTests {
338
495
  observedText.count
339
496
  )
340
497
  clearTextInput(repairTarget)
341
- app.typeText(expectedText)
342
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
498
+ repairTarget.typeText(expectedText)
499
+ return verifyTextEntry(
500
+ app: app,
501
+ target: target.withElement(repairTarget),
502
+ expectedText: expectedText,
503
+ repaired: true
504
+ )
343
505
  #else
344
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
506
+ return initialResult
345
507
  #endif
346
508
  }
347
509
 
@@ -116,6 +116,12 @@ extension RunnerTests {
116
116
  completion((jsonResponse(status: 200, response: executeUptime()), false))
117
117
  return
118
118
  }
119
+ // Re-sends of a still-executing commandId (the daemon's transport retry loop) attach to
120
+ // the in-flight execution and receive its response instead of piling a second execution
121
+ // onto the main queue behind it (#1105 capture pileup).
122
+ if attachToInFlightCommandIfNeeded(command: command, completion: completion) {
123
+ return
124
+ }
119
125
  NSLog(
120
126
  "AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=%@ commandId=%@",
121
127
  command.command.rawValue,
@@ -131,7 +137,11 @@ extension RunnerTests {
131
137
  command.commandId ?? "",
132
138
  response.ok ? 1 : 0
133
139
  )
134
- completion((self.jsonResponse(status: 200, response: response), command.command == .shutdown))
140
+ self.deliverCommandResult(
141
+ command: command,
142
+ result: (self.jsonResponse(status: 200, response: response), command.command == .shutdown),
143
+ completion: completion
144
+ )
135
145
  } catch {
136
146
  NSLog(
137
147
  "AGENT_DEVICE_RUNNER_COMMAND_FAILED command=%@ commandId=%@ error=%@",
@@ -139,17 +149,21 @@ extension RunnerTests {
139
149
  command.commandId ?? "",
140
150
  String(describing: error)
141
151
  )
142
- completion((
143
- self.jsonResponse(
144
- status: 500,
145
- response: self.errorResponse(
146
- code: "COMMAND_FAILED",
147
- message: error.localizedDescription,
148
- hint: "Check the runner log for XCTest details, then retry after the app is foregrounded if this was a timeout or activation failure."
149
- )
152
+ self.deliverCommandResult(
153
+ command: command,
154
+ result: (
155
+ self.jsonResponse(
156
+ status: 500,
157
+ response: self.errorResponse(
158
+ code: "COMMAND_FAILED",
159
+ message: error.localizedDescription,
160
+ hint: "Check the runner log for XCTest details, then retry after the app is foregrounded if this was a timeout or activation failure."
161
+ )
162
+ ),
163
+ false
150
164
  ),
151
- false
152
- ))
165
+ completion: completion
166
+ )
153
167
  }
154
168
  }
155
169
  } catch {
@@ -167,6 +181,60 @@ extension RunnerTests {
167
181
  }
168
182
  }
169
183
 
184
+ // MARK: - In-Flight Command Coalescing
185
+
186
+ /// Returns true when this send duplicated a still-executing commandId and was attached as a
187
+ /// waiter of the in-flight execution. Otherwise marks the commandId in flight and returns
188
+ /// false so the caller enqueues the (single) execution.
189
+ private func attachToInFlightCommandIfNeeded(
190
+ command: Command,
191
+ completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void
192
+ ) -> Bool {
193
+ guard let commandId = normalizedInFlightCommandId(command.commandId) else { return false }
194
+ inFlightCommandLock.lock()
195
+ if inFlightCommandIds.contains(commandId) {
196
+ inFlightCommandWaiters[commandId, default: []].append(completion)
197
+ inFlightCommandLock.unlock()
198
+ NSLog(
199
+ "AGENT_DEVICE_RUNNER_COMMAND_COALESCED command=%@ commandId=%@",
200
+ command.command.rawValue,
201
+ commandId
202
+ )
203
+ return true
204
+ }
205
+ inFlightCommandIds.insert(commandId)
206
+ inFlightCommandLock.unlock()
207
+ return false
208
+ }
209
+
210
+ private func deliverCommandResult(
211
+ command: Command,
212
+ result: (data: Data, shouldFinish: Bool),
213
+ completion: ((data: Data, shouldFinish: Bool)) -> Void
214
+ ) {
215
+ var waiters: [((data: Data, shouldFinish: Bool)) -> Void] = []
216
+ if let commandId = normalizedInFlightCommandId(command.commandId) {
217
+ inFlightCommandLock.lock()
218
+ inFlightCommandIds.remove(commandId)
219
+ waiters = inFlightCommandWaiters.removeValue(forKey: commandId) ?? []
220
+ inFlightCommandLock.unlock()
221
+ }
222
+ completion(result)
223
+ for waiter in waiters {
224
+ waiter(result)
225
+ }
226
+ }
227
+
228
+ private func normalizedInFlightCommandId(_ commandId: String?) -> String? {
229
+ guard let trimmed = commandId?.trimmingCharacters(in: .whitespacesAndNewlines),
230
+ !trimmed.isEmpty
231
+ else {
232
+ return nil
233
+ }
234
+ return trimmed
235
+ }
236
+
237
+
170
238
  // MARK: - Response Encoding
171
239
 
172
240
  private func jsonResponse(status: Int, response: Response) -> Data {
@@ -203,6 +271,9 @@ extension RunnerTests {
203
271
  private func finish() {
204
272
  listener?.cancel()
205
273
  listener = nil
274
+ // Guard against double-fulfill: coalesced shutdown sends deliver one result to
275
+ // multiple waiters, each of which may ask to finish.
206
276
  doneExpectation?.fulfill()
277
+ doneExpectation = nil
207
278
  }
208
279
  }