agent-device 0.21.8 → 0.21.9

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 (171) hide show
  1. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.8.apk → agent-device-android-ime-helper-0.21.9.apk} +0 -0
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.9.apk.sha256 +1 -0
  3. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.8.manifest.json → agent-device-android-ime-helper-0.21.9.manifest.json} +4 -4
  4. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.21.8.apk → agent-device-android-snapshot-helper-0.21.9.apk} +0 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.9.apk.sha256 +1 -0
  6. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.21.8.manifest.json → agent-device-android-snapshot-helper-0.21.9.manifest.json} +6 -6
  7. package/apple/macos-helper/Sources/AgentDeviceMacOSDeviceHub/DeviceHubWindowInventory.swift +65 -0
  8. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubPose.swift +45 -140
  9. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubSidebar.swift +57 -0
  10. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/DeviceHubWindowDiscovery.swift +108 -0
  11. package/apple/macos-helper/Tests/AgentDeviceMacOSDeviceHubTests/DeviceHubWindowInventoryTests.swift +65 -0
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift +175 -0
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +10 -2
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +42 -15
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +8 -0
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +45 -22
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +39 -734
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +20 -4
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +63 -1
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +13 -0
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +25 -10
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +5 -562
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +566 -0
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +5 -1
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +594 -0
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +35 -1
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +160 -0
  29. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/RecordingExportSupport.swift +3 -0
  30. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-frames.swift +203 -0
  31. package/dist/apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift +5 -0
  32. package/dist/src/absence-observation-errors.js +1 -1
  33. package/dist/src/adapter.js +1 -1
  34. package/dist/src/agent-browser-lifecycle.js +1 -1
  35. package/dist/src/agent-browser-process-record.js +1 -1
  36. package/dist/src/agent-device-client.js +1 -1
  37. package/dist/src/alert-runtime.js +1 -1
  38. package/dist/src/android-adb.js +1 -1
  39. package/dist/src/android-input-ownership.js +1 -1
  40. package/dist/src/android.js +1 -1
  41. package/dist/src/app-launch.js +1 -1
  42. package/dist/src/app-log-runtime2.js +3 -3
  43. package/dist/src/app-resolution.js +2 -2
  44. package/dist/src/apple-runner-platform.js +1 -1
  45. package/dist/src/application-lifecycle-interaction.js +1 -1
  46. package/dist/src/artifact-paths.js +1 -0
  47. package/dist/src/back-mode.js +1 -1
  48. package/dist/src/cli-config.js +1 -1
  49. package/dist/src/cli-help.js +8 -7
  50. package/dist/src/cli.js +4 -4
  51. package/dist/src/client-types.d.ts +23 -5
  52. package/dist/src/command-tools.js +3 -3
  53. package/dist/src/connection-runtime.js +1 -1
  54. package/dist/src/device-claim-inspection.js +1 -1
  55. package/dist/src/device-claim-rule.js +2 -2
  56. package/dist/src/device-input-state.d.ts +1 -1
  57. package/dist/src/device-selection-resolver.js +1 -1
  58. package/dist/src/device.js +1 -1
  59. package/dist/src/device2.js +1 -1
  60. package/dist/src/dispatch-resolve.js +1 -1
  61. package/dist/src/doctor-facade.js +1 -1
  62. package/dist/src/durable-resource.js +1 -1
  63. package/dist/src/find.js +1 -1
  64. package/dist/src/finders.js +1 -1
  65. package/dist/src/fold-runtime.js +1 -0
  66. package/dist/src/gesture-admission.js +1 -1
  67. package/dist/src/interaction-positionals.js +1 -1
  68. package/dist/src/interaction-ref-policy.js +1 -1
  69. package/dist/src/interaction.js +1 -1
  70. package/dist/src/interaction2.js +1 -1
  71. package/dist/src/interactor-operation-catalog.js +1 -1
  72. package/dist/src/interactor.js +4 -4
  73. package/dist/src/interactors.js +1 -1
  74. package/dist/src/internal/bin.js +1 -1
  75. package/dist/src/internal/daemon.js +2 -2
  76. package/dist/src/inventory2.js +1 -1
  77. package/dist/src/ios-snapshot-acquisition.js +1 -1
  78. package/dist/src/ios-snapshot-runtime.js +1 -1
  79. package/dist/src/lease.js +1 -1
  80. package/dist/src/local-interactor-operation-set.js +1 -1
  81. package/dist/src/location-coordinates.js +1 -1
  82. package/dist/src/managed-device-scope.js +1 -1
  83. package/dist/src/mechanics.js +17 -17
  84. package/dist/src/mobile-snapshot-semantics.js +1 -1
  85. package/dist/src/mp4-duration.js +1 -0
  86. package/dist/src/multitouch-support.js +1 -1
  87. package/dist/src/owner-identity.js +1 -1
  88. package/dist/src/parameterized-recorded-fill.js +1 -1
  89. package/dist/src/perf-facade.js +2 -2
  90. package/dist/src/perf-runtime-plan.js +1 -1
  91. package/dist/src/platform-request-scope.js +1 -1
  92. package/dist/src/platform-runtime-managed-owner.js +1 -1
  93. package/dist/src/platform-runtime-open-target.js +1 -1
  94. package/dist/src/platform-runtime-operation-host.js +2 -2
  95. package/dist/src/platform-runtime-runtime-hints.js +1 -1
  96. package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -1
  97. package/dist/src/platform-runtime-unavailable.js +1 -1
  98. package/dist/src/platform-runtime.js +1 -1
  99. package/dist/src/platform-runtime2.js +1 -1
  100. package/dist/src/png-crop-bytes.js +1 -1
  101. package/dist/src/png-encode.js +1 -0
  102. package/dist/src/png-size.js +1 -0
  103. package/dist/src/provider-webdriver.js +1 -1
  104. package/dist/src/quality-warnings.js +1 -1
  105. package/dist/src/react-native.js +1 -1
  106. package/dist/src/record-runtime.js +1 -1
  107. package/dist/src/recording-export-quality.js +1 -1
  108. package/dist/src/recording-scope.js +1 -0
  109. package/dist/src/recording.js +3 -0
  110. package/dist/src/registry.js +35 -35
  111. package/dist/src/replay.js +2 -2
  112. package/dist/src/request-admission.js +1 -1
  113. package/dist/src/request-runtime-binding.js +1 -1
  114. package/dist/src/result-serialization.js +1 -1
  115. package/dist/src/runner-cache-metadata.js +2 -2
  116. package/dist/src/runner-client.js +1 -1
  117. package/dist/src/runner-contract.js +1 -1
  118. package/dist/src/runner-dev-tools-security.js +1 -1
  119. package/dist/src/runner-device-readiness.js +1 -1
  120. package/dist/src/runner-disposal.js +1 -1
  121. package/dist/src/runner-operations-facade.js +1 -1
  122. package/dist/src/runner-presentation.js +1 -1
  123. package/dist/src/runner.js +1 -1
  124. package/dist/src/runtime.js +2 -2
  125. package/dist/src/runtime10.js +2 -1
  126. package/dist/src/runtime2.js +4 -4
  127. package/dist/src/runtime4.js +5 -5
  128. package/dist/src/runtime5.js +1 -1
  129. package/dist/src/runtime6.js +1 -1
  130. package/dist/src/runtime7.js +1 -1
  131. package/dist/src/runtime8.js +1 -1
  132. package/dist/src/runtime9.js +1 -1
  133. package/dist/src/screen-recording-admission-ledger.js +1 -1
  134. package/dist/src/screen-recording-resource-recovery.js +1 -1
  135. package/dist/src/screen-recording-transport.js +1 -1
  136. package/dist/src/screenshot-density.js +1 -1
  137. package/dist/src/screenshot-overlay-draw.js +1 -0
  138. package/dist/src/screenshot-runtime.js +1 -1
  139. package/dist/src/script.js +1 -1
  140. package/dist/src/scroll-gesture.js +1 -1
  141. package/dist/src/sdk-batch.js +2 -2
  142. package/dist/src/sdk-contracts.d.ts +7 -5
  143. package/dist/src/sdk-device.js +1 -1
  144. package/dist/src/sdk-finders.js +1 -1
  145. package/dist/src/sdk-selectors.d.ts +44 -1
  146. package/dist/src/sdk-selectors.js +1 -1
  147. package/dist/src/selection.js +0 -0
  148. package/dist/src/selector-runtime.js +1 -1
  149. package/dist/src/selectors.js +1 -1
  150. package/dist/src/semantic-index.js +1 -1
  151. package/dist/src/session-routing.js +1 -1
  152. package/dist/src/session-snapshot.js +1 -1
  153. package/dist/src/session-store.js +3 -3
  154. package/dist/src/session2.js +3 -3
  155. package/dist/src/simctl.js +1 -1
  156. package/dist/src/simulator-boot.js +1 -1
  157. package/dist/src/snapshot-runtime-binding.js +1 -1
  158. package/dist/src/snapshot-runtime.js +1 -1
  159. package/dist/src/snapshot-state.js +1 -1
  160. package/dist/src/snapshot4.js +1 -1
  161. package/dist/src/src.js +1 -1
  162. package/dist/src/src7.js +1 -1
  163. package/dist/src/stop-sequence.js +1 -1
  164. package/dist/src/text-entry-focus.js +1 -1
  165. package/dist/src/tree.js +1 -1
  166. package/dist/src/type-text-runtime.js +1 -1
  167. package/dist/src/video.js +2 -2
  168. package/package.json +1 -1
  169. package/server.json +2 -2
  170. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.8.apk.sha256 +0 -1
  171. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.8.apk.sha256 +0 -1
@@ -0,0 +1 @@
1
+ abf226740c34ca523708302fae272010cc2616b651b6073737c50f909f672b38 agent-device-android-ime-helper-0.21.9.apk
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "android-ime-helper",
3
- "version": "0.21.8",
4
- "assetName": "agent-device-android-ime-helper-0.21.8.apk",
5
- "sha256": "d2661c9b0183854af3266f08aa084c1e6f5171be78c468333a8166b068482327",
3
+ "version": "0.21.9",
4
+ "assetName": "agent-device-android-ime-helper-0.21.9.apk",
5
+ "sha256": "abf226740c34ca523708302fae272010cc2616b651b6073737c50f909f672b38",
6
6
  "packageName": "com.callstack.agentdevice.imehelper",
7
- "versionCode": 21008,
7
+ "versionCode": 21009,
8
8
  "serviceComponent": "com.callstack.agentdevice.imehelper/.TestInputMethodService",
9
9
  "broadcastProtocol": "android-ime-helper-v1"
10
10
  }
@@ -0,0 +1 @@
1
+ c73eed5a190579623e2c6b5baa2b3f7e760a5d41d4630e05d970826007d4ae55 agent-device-android-snapshot-helper-0.21.9.apk
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "android-snapshot-helper",
3
- "version": "0.21.8",
4
- "releaseTag": "v0.21.8",
5
- "assetName": "agent-device-android-snapshot-helper-0.21.8.apk",
3
+ "version": "0.21.9",
4
+ "releaseTag": "v0.21.9",
5
+ "assetName": "agent-device-android-snapshot-helper-0.21.9.apk",
6
6
  "apkUrl": null,
7
- "sha256": "4a7a293f798f3c0349e23170d886b7defcd8eaad3f47b98c431185f033d7f800",
8
- "checksumName": "agent-device-android-snapshot-helper-0.21.8.apk.sha256",
7
+ "sha256": "c73eed5a190579623e2c6b5baa2b3f7e760a5d41d4630e05d970826007d4ae55",
8
+ "checksumName": "agent-device-android-snapshot-helper-0.21.9.apk.sha256",
9
9
  "packageName": "com.callstack.agentdevice.snapshothelper",
10
- "versionCode": 21008,
10
+ "versionCode": 21009,
11
11
  "instrumentationRunner": "com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation",
12
12
  "minSdk": 23,
13
13
  "targetSdk": 36,
@@ -0,0 +1,65 @@
1
+ import ApplicationServices
2
+
3
+ package enum DeviceHubWindowRead {
4
+ case available([AXUIElement])
5
+ case failed(Int32)
6
+ }
7
+
8
+ package struct DeviceHubWindowCandidate {
9
+ package let processID: Int32
10
+ package let window: AXUIElement
11
+ }
12
+
13
+ package enum DeviceHubRowLookup {
14
+ case identified(AXUIElement, restoreSidebar: Bool)
15
+ case sidebarUnavailable
16
+ case unconfirmed
17
+ case timedOut
18
+
19
+ package var diagnostic: String {
20
+ switch self {
21
+ case .identified: return "identified"
22
+ case .sidebarUnavailable: return "sidebar-unavailable"
23
+ case .unconfirmed: return "row-unconfirmed"
24
+ case .timedOut: return "timed-out"
25
+ }
26
+ }
27
+ }
28
+
29
+ package struct DeviceHubWindowInventory {
30
+ package private(set) var candidates: [DeviceHubWindowCandidate] = []
31
+ package private(set) var emptyProcessIDs: [Int32] = []
32
+ package private(set) var failures: [Int32: Int32] = [:]
33
+ package private(set) var candidateOutcomes: [String] = []
34
+ package private(set) var budgetExpired = false
35
+
36
+ package init() {}
37
+
38
+ package mutating func record(processID: Int32, read: DeviceHubWindowRead) {
39
+ switch read {
40
+ case .available(let windows):
41
+ if windows.isEmpty { emptyProcessIDs.append(processID) }
42
+ candidates.append(contentsOf: windows.map { DeviceHubWindowCandidate(processID: processID, window: $0) })
43
+ case .failed(let status):
44
+ failures[processID] = status
45
+ }
46
+ }
47
+
48
+ package mutating func record(candidate: Int, outcome: DeviceHubRowLookup) {
49
+ candidateOutcomes.append("\(candidates[candidate].processID):\(candidate):\(outcome.diagnostic)")
50
+ }
51
+
52
+ package mutating func expireBudget() { budgetExpired = true }
53
+
54
+ package func processesToReopen(attempted: Set<Int32>) -> [Int32] {
55
+ budgetExpired ? [] : emptyProcessIDs.filter { !attempted.contains($0) }
56
+ }
57
+
58
+ package func failureReason(reopenFailures: [Int32: String] = [:]) -> String {
59
+ if !failures.isEmpty { return "device-hub-window-read-failed" }
60
+ if budgetExpired { return "device-hub-discovery-timeout" }
61
+ if !candidates.isEmpty { return "device-hub-identity-unconfirmed" }
62
+ if let reason = reopenFailures.sorted(by: { $0.key < $1.key }).first?.value { return reason }
63
+ return "device-hub-window-missing"
64
+ }
65
+ }
@@ -4,12 +4,7 @@ import ApplicationServices
4
4
  import Darwin
5
5
  import Foundation
6
6
 
7
- /// The Xcode Device Hub application. Its device windows carry an action bar whose pose controls
8
- /// are the only public seam onto the private channel that folds a simulator.
9
- private let deviceHubBundleId = "com.apple.dt.Devices"
10
-
11
- /// How long a reopen gets to restore a window, and a sidebar selection to switch the window's
12
- /// device, before the press is refused.
7
+ /// How long sidebar selection and pose-control discovery may each wait before refusing a press.
13
8
  private let deviceHubSettleDeadline: TimeInterval = 8
14
9
  private let deviceHubPoll: TimeInterval = 0.25
15
10
 
@@ -51,16 +46,15 @@ func handleDeviceHub(arguments: [String]) throws -> any Encodable {
51
46
  details: ["reason": "accessibility-permission", "permission": "accessibility"]
52
47
  )
53
48
  }
54
- guard let pid = deviceHubProcessIdentifier() else {
55
- throw HelperError.commandFailed(
56
- "Xcode Device Hub is not running",
57
- details: ["reason": "device-hub-not-running", "bundleId": deviceHubBundleId]
58
- )
49
+ let discovery = try discoverDeviceHubWindow(udid: udid, deviceName: deviceName)
50
+ let window = discovery.window
51
+ defer {
52
+ if discovery.revealedSidebar {
53
+ _ = setDeviceHubSidebar(visible: false, window: window,
54
+ deadline: ProcessInfo.processInfo.systemUptime + 0.5)
55
+ }
59
56
  }
60
-
61
- let appElement = AXUIElementCreateApplication(pid)
62
- let (window, reopened) = try deviceHubWindow(in: appElement, pid: pid, deviceName: deviceName)
63
- let selected = try selectDevice(udid: udid, deviceName: deviceName, in: window, appElement: appElement)
57
+ let selected = try selectDevice(row: discovery.row, deviceName: deviceName, in: window)
64
58
  let windowTitle = stringAttribute(window, attribute: kAXTitleAttribute as String) ?? ""
65
59
  guard let button = awaitPoseButton(in: window, description: control) else {
66
60
  throw HelperError.commandFailed(
@@ -84,64 +78,12 @@ func handleDeviceHub(arguments: [String]) throws -> any Encodable {
84
78
  pose: pose.rawValue,
85
79
  control: control,
86
80
  windowTitle: windowTitle,
87
- reopened: reopened,
81
+ reopened: discovery.reopened,
88
82
  selected: selected
89
83
  )
90
84
  )
91
85
  }
92
86
 
93
- /// Device Hub is launched through a trampoline, so LaunchServices registers it with no process
94
- /// identifier (`NSRunningApplication.processIdentifier` is -1) and an accessibility element built
95
- /// from that identifier is invalid. The process table is the only place its real pid appears.
96
- private func deviceHubProcessIdentifier() -> pid_t? {
97
- let executableSuffix = deviceHubExecutableSuffix
98
- let byteCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0)
99
- guard byteCount > 0 else { return nil }
100
- var pids = [pid_t](repeating: 0, count: Int(byteCount) / MemoryLayout<pid_t>.size + 64)
101
- let filled = proc_listpids(
102
- UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size)
103
- )
104
- guard filled > 0 else { return nil }
105
- var path = [CChar](repeating: 0, count: 4096)
106
- for pid in pids.prefix(Int(filled) / MemoryLayout<pid_t>.size) where pid > 0 {
107
- guard proc_pidpath(pid, &path, UInt32(path.count)) > 0 else { continue }
108
- if String(cString: path).hasSuffix(executableSuffix) {
109
- return pid
110
- }
111
- }
112
- return nil
113
- }
114
-
115
- /// A device window to drive: the one already showing this device when there is one, otherwise
116
- /// any device window, since its sidebar can switch it to the device. A Device Hub with no window
117
- /// at all — a simulator booted headlessly leaves it that way — is asked to reopen one.
118
- private func deviceHubWindow(
119
- in appElement: AXUIElement,
120
- pid: pid_t,
121
- deviceName: String
122
- ) throws -> (window: AXUIElement, reopened: Bool) {
123
- if let window = preferredWindow(in: appElement, deviceName: deviceName) {
124
- return (window, false)
125
- }
126
- try sendReopenEvent(to: pid)
127
- let deadline = Date().addingTimeInterval(deviceHubSettleDeadline)
128
- while Date() < deadline {
129
- Thread.sleep(forTimeInterval: deviceHubPoll)
130
- if let window = preferredWindow(in: appElement, deviceName: deviceName) {
131
- return (window, true)
132
- }
133
- }
134
- throw HelperError.commandFailed(
135
- "Device Hub shows no device window to drive",
136
- details: ["reason": "device-hub-window-missing", "deviceName": deviceName]
137
- )
138
- }
139
-
140
- private func preferredWindow(in appElement: AXUIElement, deviceName: String) -> AXUIElement? {
141
- let candidates = windows(of: appElement)
142
- return candidates.first { windowShows(deviceName: deviceName, $0) } ?? candidates.first
143
- }
144
-
145
87
  private func windowShows(deviceName: String, _ window: AXUIElement) -> Bool {
146
88
  return deviceHubWindowShows(
147
89
  deviceName: deviceName,
@@ -152,7 +94,7 @@ private func windowShows(deviceName: String, _ window: AXUIElement) -> Bool {
152
94
  /// `kAEReopenApplication` is what the Dock sends when an app with no open windows is clicked, and
153
95
  /// it is the one event Device Hub answers by restoring the device window. Sent to the process
154
96
  /// directly, because LaunchServices cannot address a trampolined app by bundle identifier.
155
- private func sendReopenEvent(to pid: pid_t) throws {
97
+ func sendDeviceHubReopenEvent(to pid: pid_t, timeout: TimeInterval) throws {
156
98
  let target = NSAppleEventDescriptor(processIdentifier: pid)
157
99
  let event = NSAppleEventDescriptor(
158
100
  eventClass: AEEventClass(kCoreEventClass),
@@ -162,7 +104,7 @@ private func sendReopenEvent(to pid: pid_t) throws {
162
104
  transactionID: AETransactionID(kAnyTransactionID)
163
105
  )
164
106
  do {
165
- _ = try event.sendEvent(options: [.noReply], timeout: 5)
107
+ _ = try event.sendEvent(options: [.noReply], timeout: min(5, max(0.001, timeout)))
166
108
  } catch {
167
109
  let code = (error as NSError).code
168
110
  throw HelperError.commandFailed(
@@ -178,66 +120,43 @@ private func sendReopenEvent(to pid: pid_t) throws {
178
120
  /// Switches the window to the device through its sidebar row, whose accessibility identifier is
179
121
  /// `TableRow.Device.<udid>` — the one place Device Hub exposes a device identity that two
180
122
  /// simulators sharing a name cannot confuse. A window already titled with the device still gets
181
- /// the selection when the row is visible, because the title alone cannot tell such twins apart.
123
+ /// the selection, because the title alone cannot tell such twins apart.
182
124
  private func selectDevice(
183
- udid: String,
125
+ row: AXUIElement,
184
126
  deviceName: String,
185
- in window: AXUIElement,
186
- appElement: AXUIElement
127
+ in window: AXUIElement
187
128
  ) throws -> Bool {
188
- var shownSidebar = false
189
- var row = deviceRow(udid: udid, in: window)
190
- if row == nil, showSidebar(appElement: appElement) {
191
- shownSidebar = true
192
- let deadline = Date().addingTimeInterval(deviceHubSettleDeadline)
193
- while row == nil, Date() < deadline {
194
- Thread.sleep(forTimeInterval: deviceHubPoll)
195
- row = deviceRow(udid: udid, in: window)
196
- }
197
- }
198
- defer {
199
- if shownSidebar { _ = pressMenuItem(appElement: appElement, menu: "View", item: "Hide Sidebar") }
200
- }
201
- guard let row else {
202
- if windowShows(deviceName: deviceName, window) {
203
- return false
204
- }
205
- throw HelperError.commandFailed(
206
- "Device Hub lists no device \(udid) in its sidebar",
207
- details: ["reason": "device-hub-device-missing", "udid": udid, "deviceName": deviceName]
208
- )
209
- }
129
+ let deadline = ProcessInfo.processInfo.systemUptime + deviceHubSettleDeadline
130
+ AXUIElementSetMessagingTimeout(row, 0.25)
131
+ AXUIElementSetMessagingTimeout(window, 0.25)
210
132
  let status = AXUIElementSetAttributeValue(row, kAXSelectedAttribute as CFString, kCFBooleanTrue)
211
- guard status == .success else {
133
+ guard status == .success || status == .cannotComplete else {
212
134
  throw HelperError.commandFailed(
213
135
  "Device Hub refused to select \(deviceName) in its sidebar",
214
136
  details: ["reason": "device-hub-select-failed", "status": "\(status.rawValue)"]
215
137
  )
216
138
  }
217
- let deadline = Date().addingTimeInterval(deviceHubSettleDeadline)
218
- while !windowShows(deviceName: deviceName, window), Date() < deadline {
139
+ while ProcessInfo.processInfo.systemUptime < deadline {
140
+ var selected: CFTypeRef?
141
+ if AXUIElementCopyAttributeValue(row, kAXSelectedAttribute as CFString, &selected) == .success,
142
+ (selected as? Bool) == true, windowShows(deviceName: deviceName, window) { return true }
219
143
  Thread.sleep(forTimeInterval: deviceHubPoll)
220
144
  }
221
- guard windowShows(deviceName: deviceName, window) else {
222
- throw HelperError.commandFailed(
223
- "Device Hub did not switch its window to \(deviceName)",
224
- details: [
225
- "reason": "device-hub-select-unconfirmed",
226
- "windowTitle": stringAttribute(window, attribute: kAXTitleAttribute as String) ?? "",
227
- ]
228
- )
229
- }
230
- return true
145
+ throw HelperError.commandFailed(
146
+ "Device Hub did not confirm selection of \(deviceName)",
147
+ details: ["reason": "device-hub-select-unconfirmed", "status": "\(status.rawValue)"]
148
+ )
231
149
  }
232
150
 
233
- private func deviceRow(udid: String, in window: AXUIElement) -> AXUIElement? {
234
- guard let label = findElement(root: window, depth: 0, where: {
151
+ func deviceHubDeviceRow(udid: String, in window: AXUIElement, deadline: TimeInterval) -> AXUIElement? {
152
+ guard let label = findDeviceHubElement(root: window, depth: 0, deadline: deadline, where: {
235
153
  stringAttribute($0, attribute: "AXIdentifier") == deviceHubDeviceRowIdentifier(udid: udid)
236
154
  }) else {
237
155
  return nil
238
156
  }
239
157
  var current: AXUIElement? = label
240
- while let element = current {
158
+ while let element = current, ProcessInfo.processInfo.systemUptime < deadline {
159
+ AXUIElementSetMessagingTimeout(element, Float(max(0.001, min(0.25, deadline - ProcessInfo.processInfo.systemUptime))))
241
160
  if stringAttribute(element, attribute: kAXRoleAttribute as String) == "AXRow" {
242
161
  return element
243
162
  }
@@ -246,36 +165,16 @@ private func deviceRow(udid: String, in window: AXUIElement) -> AXUIElement? {
246
165
  return nil
247
166
  }
248
167
 
249
- private func showSidebar(appElement: AXUIElement) -> Bool {
250
- return pressMenuItem(appElement: appElement, menu: "View", item: "Show Sidebar")
251
- }
252
-
253
- private func pressMenuItem(appElement: AXUIElement, menu: String, item: String) -> Bool {
254
- guard let menuBar = elementAttribute(appElement, attribute: kAXMenuBarAttribute as String) else {
255
- return false
256
- }
257
- for menuBarItem in children(of: menuBar)
258
- where stringAttribute(menuBarItem, attribute: kAXTitleAttribute as String) == menu {
259
- for submenu in children(of: menuBarItem) {
260
- for menuItem in children(of: submenu)
261
- where stringAttribute(menuItem, attribute: kAXTitleAttribute as String) == item {
262
- return AXUIElementPerformAction(menuItem, kAXPressAction as CFString) == .success
263
- }
264
- }
265
- }
266
- return false
267
- }
268
-
269
168
  /// The action bar is rebuilt for the device the window shows, so right after a sidebar selection
270
169
  /// the window already carries the new title while the pose controls are still being laid out.
271
170
  /// The controls are therefore awaited, not looked up once.
272
171
  private func awaitPoseButton(in window: AXUIElement, description: String) -> AXUIElement? {
273
- let deadline = Date().addingTimeInterval(deviceHubSettleDeadline)
172
+ let deadline = ProcessInfo.processInfo.systemUptime + deviceHubSettleDeadline
274
173
  while true {
275
- if let button = poseButton(in: window, description: description) {
174
+ if let button = poseButton(in: window, description: description, deadline: deadline) {
276
175
  return button
277
176
  }
278
- guard Date() < deadline else { return nil }
177
+ guard ProcessInfo.processInfo.systemUptime < deadline else { return nil }
279
178
  Thread.sleep(forTimeInterval: deviceHubPoll)
280
179
  }
281
180
  }
@@ -283,29 +182,35 @@ private func awaitPoseButton(in window: AXUIElement, description: String) -> AXU
283
182
  /// The pose controls sit in the window's action bar as `AXButton`s described by their preset
284
183
  /// name; the simulated screen inside the same window is an iOS content group whose own buttons
285
184
  /// carry app labels, never these three.
286
- private func poseButton(in window: AXUIElement, description: String) -> AXUIElement? {
287
- return findElement(root: window, depth: 0) {
185
+ private func poseButton(in window: AXUIElement, description: String, deadline: TimeInterval) -> AXUIElement? {
186
+ return findDeviceHubElement(root: window, depth: 0, deadline: deadline) {
288
187
  stringAttribute($0, attribute: kAXRoleAttribute as String) == "AXButton"
289
188
  && stringAttribute($0, attribute: kAXDescriptionAttribute as String) == description
290
189
  }
291
190
  }
292
191
 
293
- private func findElement(
192
+ func findDeviceHubElement(
294
193
  root: AXUIElement,
295
194
  depth: Int,
195
+ deadline: TimeInterval,
296
196
  where matches: (AXUIElement) -> Bool
297
197
  ) -> AXUIElement? {
298
- if depth > 14 {
198
+ let remaining = deadline - ProcessInfo.processInfo.systemUptime
199
+ if depth > 14 || remaining <= 0 {
299
200
  return nil
300
201
  }
202
+ AXUIElementSetMessagingTimeout(root, Float(min(0.25, remaining)))
301
203
  for child in children(of: root) {
204
+ let remaining = deadline - ProcessInfo.processInfo.systemUptime
205
+ guard remaining > 0 else { return nil }
206
+ AXUIElementSetMessagingTimeout(child, Float(min(0.25, remaining)))
302
207
  if matches(child) {
303
208
  return child
304
209
  }
305
210
  if stringAttribute(child, attribute: kAXSubroleAttribute as String) == "iOSContentGroup" {
306
211
  continue
307
212
  }
308
- if let nested = findElement(root: child, depth: depth + 1, where: matches) {
213
+ if let nested = findDeviceHubElement(root: child, depth: depth + 1, deadline: deadline, where: matches) {
309
214
  return nested
310
215
  }
311
216
  }
@@ -0,0 +1,57 @@
1
+ import AgentDeviceMacOSDeviceHub
2
+ import ApplicationServices
3
+ import Foundation
4
+
5
+ func identifyDeviceHubRow(
6
+ udid: String,
7
+ candidate: DeviceHubWindowCandidate,
8
+ deadline: TimeInterval
9
+ ) -> DeviceHubRowLookup {
10
+ let now = ProcessInfo.processInfo.systemUptime
11
+ let visibleRowDeadline = now + max(0, deadline - now) / 3
12
+ if let row = deviceHubDeviceRow(udid: udid, in: candidate.window, deadline: visibleRowDeadline) {
13
+ return .identified(row, restoreSidebar: false)
14
+ }
15
+ let searchDeadline = deadline - 0.25
16
+ guard let button = deviceHubSidebarButton(visible: true, window: candidate.window, deadline: searchDeadline) else {
17
+ return ProcessInfo.processInfo.systemUptime >= searchDeadline ? .timedOut : .sidebarUnavailable
18
+ }
19
+ var transferCleanup = false
20
+ defer {
21
+ if !transferCleanup {
22
+ _ = setDeviceHubSidebar(visible: false, window: candidate.window, deadline: deadline)
23
+ }
24
+ }
25
+ let status = pressDeviceHubSidebarButton(button, deadline: searchDeadline)
26
+ guard status == .success || status == .cannotComplete else { return .unconfirmed }
27
+ repeat {
28
+ if let row = deviceHubDeviceRow(udid: udid, in: candidate.window, deadline: searchDeadline) {
29
+ transferCleanup = true
30
+ return .identified(row, restoreSidebar: true)
31
+ }
32
+ let remaining = searchDeadline - ProcessInfo.processInfo.systemUptime
33
+ if remaining <= 0 { return .timedOut }
34
+ Thread.sleep(forTimeInterval: min(0.05, remaining))
35
+ } while ProcessInfo.processInfo.systemUptime < searchDeadline
36
+ return .timedOut
37
+ }
38
+
39
+ func setDeviceHubSidebar(visible: Bool, window: AXUIElement, deadline: TimeInterval) -> Bool {
40
+ guard let button = deviceHubSidebarButton(visible: visible, window: window, deadline: deadline) else { return false }
41
+ return pressDeviceHubSidebarButton(button, deadline: deadline) == .success
42
+ }
43
+
44
+ private func deviceHubSidebarButton(visible: Bool, window: AXUIElement, deadline: TimeInterval) -> AXUIElement? {
45
+ let description = visible ? "Show Sidebar" : "Hide Sidebar"
46
+ return findDeviceHubElement(root: window, depth: 0, deadline: deadline) {
47
+ stringAttribute($0, attribute: kAXRoleAttribute as String) == "AXButton"
48
+ && stringAttribute($0, attribute: kAXDescriptionAttribute as String) == description
49
+ }
50
+ }
51
+
52
+ private func pressDeviceHubSidebarButton(_ button: AXUIElement, deadline: TimeInterval) -> AXError {
53
+ let remaining = deadline - ProcessInfo.processInfo.systemUptime
54
+ guard remaining > 0 else { return .cannotComplete }
55
+ AXUIElementSetMessagingTimeout(button, Float(min(0.1, remaining)))
56
+ return AXUIElementPerformAction(button, kAXPressAction as CFString)
57
+ }
@@ -0,0 +1,108 @@
1
+ import AgentDeviceMacOSDeviceHub
2
+ import AppKit
3
+ import ApplicationServices
4
+ import Darwin
5
+
6
+ struct DiscoveredDeviceHubWindow {
7
+ let window: AXUIElement
8
+ let reopened: Bool
9
+ let row: AXUIElement
10
+ let revealedSidebar: Bool
11
+ }
12
+
13
+ func discoverDeviceHubWindow(udid: String, deviceName: String) throws -> DiscoveredDeviceHubWindow {
14
+ let deadline = ProcessInfo.processInfo.systemUptime + 8
15
+ var reopened = Set<pid_t>()
16
+ var reopenFailures: [pid_t: String] = [:]
17
+ var inventory = DeviceHubWindowInventory()
18
+ var processIDs: [pid_t] = []
19
+ repeat {
20
+ processIDs = deviceHubProcessIdentifiers()
21
+ if processIDs.isEmpty {
22
+ throw HelperError.commandFailed(
23
+ "Xcode Device Hub is not running",
24
+ details: ["reason": "device-hub-not-running", "bundleId": "com.apple.dt.Devices"]
25
+ )
26
+ }
27
+ inventory = DeviceHubWindowInventory()
28
+ for pid in processIDs {
29
+ let remaining = deadline - ProcessInfo.processInfo.systemUptime
30
+ guard remaining > 0 else { inventory.expireBudget(); break }
31
+ let app = AXUIElementCreateApplication(pid)
32
+ AXUIElementSetMessagingTimeout(app, Float(min(0.5, remaining)))
33
+ var value: CFTypeRef?
34
+ let status = AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value)
35
+ if status == .success, let windows = value as? [AXUIElement] {
36
+ for window in windows { AXUIElementSetMessagingTimeout(window, 0.5) }
37
+ inventory.record(processID: pid, read: .available(windows))
38
+ } else {
39
+ inventory.record(processID: pid, read: .failed(
40
+ status == .success ? AXError.illegalArgument.rawValue : status.rawValue
41
+ ))
42
+ }
43
+ }
44
+ for (index, candidate) in inventory.candidates.enumerated() {
45
+ let now = ProcessInfo.processInfo.systemUptime
46
+ guard now < deadline else { inventory.expireBudget(); break }
47
+ let candidateDeadline = now + (deadline - now) / Double(inventory.candidates.count - index) / 2
48
+ let outcome = identifyDeviceHubRow(udid: udid, candidate: candidate, deadline: candidateDeadline)
49
+ inventory.record(candidate: index, outcome: outcome)
50
+ if case .identified(let row, let restoreSidebar) = outcome {
51
+ return DiscoveredDeviceHubWindow(window: candidate.window,
52
+ reopened: reopened.contains(candidate.processID), row: row, revealedSidebar: restoreSidebar)
53
+ }
54
+ }
55
+ let attempted = reopened.union(reopenFailures.keys)
56
+ for pid in inventory.processesToReopen(attempted: attempted) {
57
+ guard ProcessInfo.processInfo.systemUptime < deadline else { inventory.expireBudget(); break }
58
+ do {
59
+ try sendDeviceHubReopenEvent(to: pid, timeout: deadline - ProcessInfo.processInfo.systemUptime)
60
+ reopened.insert(pid)
61
+ } catch let error as HelperError {
62
+ guard case .commandFailed(_, let details) = error else { throw error }
63
+ reopenFailures[pid] = details["reason"] ?? "device-hub-reopen-failed"
64
+ }
65
+ }
66
+ let remaining = deadline - ProcessInfo.processInfo.systemUptime
67
+ if remaining <= 0 { inventory.expireBudget(); break }
68
+ Thread.sleep(forTimeInterval: min(0.25, remaining))
69
+ } while ProcessInfo.processInfo.systemUptime < deadline
70
+
71
+ let statuses = inventory.failures.sorted { $0.key < $1.key }
72
+ .map { "\($0.key):\($0.value)" }.joined(separator: ",")
73
+ throw HelperError.commandFailed(
74
+ "Could not confirm a Device Hub window for the requested device",
75
+ details: [
76
+ "reason": inventory.failureReason(reopenFailures: reopenFailures),
77
+ "reopenFailures": reopenFailures.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" }.joined(separator: ","),
78
+ "candidateOutcomes": inventory.candidateOutcomes.joined(separator: ","),
79
+ "udid": udid,
80
+ "deviceName": deviceName,
81
+ "processIDs": processIDs.map(String.init).joined(separator: ","),
82
+ "axWindowReadStatuses": statuses,
83
+ "hostDisplayIDs": deviceHubHostDisplays().map { String($0) }.joined(separator: ","),
84
+ ]
85
+ )
86
+ }
87
+
88
+ private func deviceHubProcessIdentifiers() -> [pid_t] {
89
+ let byteCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0)
90
+ guard byteCount > 0 else { return [] }
91
+ var pids = [pid_t](repeating: 0, count: Int(byteCount) / MemoryLayout<pid_t>.size + 64)
92
+ let filled = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
93
+ guard filled > 0 else { return [] }
94
+ return pids.prefix(Int(filled) / MemoryLayout<pid_t>.size).filter { pid in
95
+ guard pid > 0 else { return false }
96
+ var path = [CChar](repeating: 0, count: 4096)
97
+ guard proc_pidpath(pid, &path, UInt32(path.count)) > 0 else { return false }
98
+ return String(cString: path).hasSuffix(deviceHubExecutableSuffix)
99
+ }.sorted()
100
+ }
101
+
102
+ private func deviceHubHostDisplays() -> [CGDirectDisplayID] {
103
+ var count: UInt32 = 0
104
+ guard CGGetActiveDisplayList(0, nil, &count) == .success else { return [] }
105
+ var displays = [CGDirectDisplayID](repeating: 0, count: Int(count))
106
+ guard CGGetActiveDisplayList(count, &displays, &count) == .success else { return [] }
107
+ return Array(displays.prefix(Int(count)))
108
+ }
@@ -0,0 +1,65 @@
1
+ import ApplicationServices
2
+ import XCTest
3
+ @testable import AgentDeviceMacOSDeviceHub
4
+
5
+ final class DeviceHubWindowInventoryTests: XCTestCase {
6
+ func testLaterWindowKeepsItsProcessAndDoesNotPreventEmptyProcessReopen() {
7
+ var inventory = DeviceHubWindowInventory()
8
+ let window = AXUIElementCreateApplication(20)
9
+ inventory.record(processID: 10, read: .available([]))
10
+ inventory.record(processID: 20, read: .available([window]))
11
+ XCTAssertEqual(inventory.candidates.map(\.processID), [20])
12
+ XCTAssertTrue(CFEqual(inventory.candidates[0].window, window))
13
+ inventory.record(candidate: 0, outcome: .sidebarUnavailable)
14
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [10])
15
+ XCTAssertEqual(inventory.failureReason(), "device-hub-identity-unconfirmed")
16
+ XCTAssertEqual(inventory.candidateOutcomes, ["20:0:sidebar-unavailable"])
17
+ }
18
+
19
+ func testFailedReadsAreNotReopenedOrMisreportedAsMissingWindows() {
20
+ var inventory = DeviceHubWindowInventory()
21
+ inventory.record(processID: 10, read: .failed(-25204))
22
+ inventory.record(processID: 20, read: .available([]))
23
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [20])
24
+ XCTAssertEqual(inventory.failures, [10: -25204])
25
+ XCTAssertEqual(inventory.failureReason(), "device-hub-window-read-failed")
26
+ }
27
+
28
+ func testSuccessfulEmptyReadIsMissingAndReopenIsAttemptedOnlyOnce() {
29
+ var inventory = DeviceHubWindowInventory()
30
+ inventory.record(processID: 10, read: .available([]))
31
+ XCTAssertEqual(inventory.failureReason(), "device-hub-window-missing")
32
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [10])
33
+ XCTAssertEqual(inventory.processesToReopen(attempted: [10]), [])
34
+ }
35
+
36
+ func testSharedBudgetExhaustionStopsReopenWithoutClaimingMissingDevice() {
37
+ var inventory = DeviceHubWindowInventory()
38
+ inventory.record(processID: 10, read: .available([]))
39
+ inventory.expireBudget()
40
+ XCTAssertEqual(inventory.processesToReopen(attempted: []), [])
41
+ XCTAssertEqual(inventory.failureReason(), "device-hub-discovery-timeout")
42
+ }
43
+
44
+ func testCandidateOutcomesRetainUncertaintyAndOwningProcess() {
45
+ var inventory = DeviceHubWindowInventory()
46
+ inventory.record(processID: 10, read: .available([AXUIElementCreateApplication(10)]))
47
+ inventory.record(processID: 20, read: .available([AXUIElementCreateApplication(20)]))
48
+ inventory.record(candidate: 0, outcome: .timedOut)
49
+ inventory.record(candidate: 1, outcome: .unconfirmed)
50
+ XCTAssertEqual(inventory.candidateOutcomes, ["10:0:timed-out", "20:1:row-unconfirmed"])
51
+ XCTAssertEqual(inventory.failureReason(), "device-hub-identity-unconfirmed")
52
+ }
53
+ func testUnrelatedReopenFailureDoesNotMaskCandidateUncertainty() {
54
+ var inventory = DeviceHubWindowInventory()
55
+ inventory.record(processID: 10, read: .available([]))
56
+ let failures: [Int32: String] = [10: "automation-permission"]
57
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "automation-permission")
58
+ inventory.record(processID: 20, read: .available([AXUIElementCreateApplication(20)]))
59
+ inventory.record(candidate: 0, outcome: .timedOut)
60
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "device-hub-identity-unconfirmed")
61
+ inventory.expireBudget()
62
+ XCTAssertEqual(inventory.failureReason(reopenFailures: failures), "device-hub-discovery-timeout")
63
+ }
64
+
65
+ }
@@ -2,3 +2,4 @@
2
2
  #import "RunnerAXSnapshotBridge.h"
3
3
  #import "RunnerSynthesizedGesture.h"
4
4
  #import "RunnerSynthesizedTextEntry.h"
5
+ #import "RunnerXCTestEventBridge.h"