agent-device 0.7.3 → 0.7.5

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.
@@ -0,0 +1,258 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Navigation Gestures
5
+
6
+ func tapNavigationBack(app: XCUIApplication) -> Bool {
7
+ let buttons = app.navigationBars.buttons.allElementsBoundByIndex
8
+ if let back = buttons.first(where: { $0.isHittable }) {
9
+ back.tap()
10
+ return true
11
+ }
12
+ return pressTvRemoteMenuIfAvailable()
13
+ }
14
+
15
+ func performBackGesture(app: XCUIApplication) {
16
+ if pressTvRemoteMenuIfAvailable() {
17
+ return
18
+ }
19
+ let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
20
+ let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
21
+ let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
22
+ start.press(forDuration: 0.05, thenDragTo: end)
23
+ }
24
+
25
+ func performAppSwitcherGesture(app: XCUIApplication) {
26
+ if performTvRemoteAppSwitcherIfAvailable() {
27
+ return
28
+ }
29
+ let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
30
+ let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
31
+ let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
32
+ start.press(forDuration: 0.6, thenDragTo: end)
33
+ }
34
+
35
+ func pressHomeButton() {
36
+ if pressTvRemoteHomeIfAvailable() {
37
+ return
38
+ }
39
+ XCUIDevice.shared.press(.home)
40
+ }
41
+
42
+ private func pressTvRemoteMenuIfAvailable() -> Bool {
43
+ #if os(tvOS)
44
+ XCUIRemote.shared.press(.menu)
45
+ return true
46
+ #else
47
+ return false
48
+ #endif
49
+ }
50
+
51
+ private func pressTvRemoteHomeIfAvailable() -> Bool {
52
+ #if os(tvOS)
53
+ XCUIRemote.shared.press(.home)
54
+ return true
55
+ #else
56
+ return false
57
+ #endif
58
+ }
59
+
60
+ private func performTvRemoteAppSwitcherIfAvailable() -> Bool {
61
+ #if os(tvOS)
62
+ XCUIRemote.shared.press(.home)
63
+ sleepFor(resolveTvRemoteDoublePressDelay())
64
+ XCUIRemote.shared.press(.home)
65
+ return true
66
+ #else
67
+ return false
68
+ #endif
69
+ }
70
+
71
+ private func resolveTvRemoteDoublePressDelay() -> TimeInterval {
72
+ guard
73
+ let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_TV_REMOTE_DOUBLE_PRESS_DELAY_MS"],
74
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
75
+ else {
76
+ return tvRemoteDoublePressDelayDefault
77
+ }
78
+ guard let parsedMs = Double(raw), parsedMs >= 0 else {
79
+ return tvRemoteDoublePressDelayDefault
80
+ }
81
+ return min(parsedMs, 1000) / 1000.0
82
+ }
83
+
84
+ func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
85
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
86
+ let element = app.descendants(matching: .any).matching(predicate).firstMatch
87
+ return element.exists ? element : nil
88
+ }
89
+
90
+ func clearTextInput(_ element: XCUIElement) {
91
+ moveCaretToEnd(element: element)
92
+ let count = estimatedDeleteCount(for: element)
93
+ let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
94
+ element.typeText(deletes)
95
+ }
96
+
97
+ func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
98
+ let focused = app
99
+ .descendants(matching: .any)
100
+ .matching(NSPredicate(format: "hasKeyboardFocus == 1"))
101
+ .firstMatch
102
+ guard focused.exists else { return nil }
103
+
104
+ switch focused.elementType {
105
+ case .textField, .secureTextField, .searchField, .textView:
106
+ return focused
107
+ default:
108
+ return nil
109
+ }
110
+ }
111
+
112
+ private func moveCaretToEnd(element: XCUIElement) {
113
+ let frame = element.frame
114
+ guard !frame.isEmpty else {
115
+ element.tap()
116
+ return
117
+ }
118
+ let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
119
+ let target = origin.withOffset(
120
+ CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
121
+ )
122
+ target.tap()
123
+ }
124
+
125
+ private func estimatedDeleteCount(for element: XCUIElement) -> Int {
126
+ let valueText = String(describing: element.value ?? "")
127
+ .trimmingCharacters(in: .whitespacesAndNewlines)
128
+ let base = valueText.isEmpty ? 24 : (valueText.count + 8)
129
+ return max(24, min(120, base))
130
+ }
131
+
132
+ func findScopeElement(app: XCUIApplication, scope: String) -> XCUIElement? {
133
+ let predicate = NSPredicate(
134
+ format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@",
135
+ scope,
136
+ scope
137
+ )
138
+ let element = app.descendants(matching: .any).matching(predicate).firstMatch
139
+ return element.exists ? element : nil
140
+ }
141
+
142
+ func tapAt(app: XCUIApplication, x: Double, y: Double) {
143
+ let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
144
+ let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
145
+ coordinate.tap()
146
+ }
147
+
148
+ func doubleTapAt(app: XCUIApplication, x: Double, y: Double) {
149
+ let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
150
+ let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
151
+ coordinate.doubleTap()
152
+ }
153
+
154
+ func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) {
155
+ let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
156
+ let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
157
+ coordinate.press(forDuration: duration)
158
+ }
159
+
160
+ func dragAt(
161
+ app: XCUIApplication,
162
+ x: Double,
163
+ y: Double,
164
+ x2: Double,
165
+ y2: Double,
166
+ holdDuration: TimeInterval
167
+ ) {
168
+ let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
169
+ let start = origin.withOffset(CGVector(dx: x, dy: y))
170
+ let end = origin.withOffset(CGVector(dx: x2, dy: y2))
171
+ start.press(forDuration: holdDuration, thenDragTo: end)
172
+ }
173
+
174
+ func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
175
+ let total = max(count, 1)
176
+ let pause = max(pauseMs, 0)
177
+ for idx in 0..<total {
178
+ operation(idx)
179
+ if idx < total - 1 && pause > 0 {
180
+ Thread.sleep(forTimeInterval: pause / 1000.0)
181
+ }
182
+ }
183
+ }
184
+
185
+ func swipe(app: XCUIApplication, direction: SwipeDirection) {
186
+ if performTvRemoteSwipeIfAvailable(direction: direction) {
187
+ return
188
+ }
189
+ let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
190
+ let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
191
+ let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
192
+ let left = target.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
193
+ let right = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
194
+
195
+ switch direction {
196
+ case .up:
197
+ end.press(forDuration: 0.1, thenDragTo: start)
198
+ case .down:
199
+ start.press(forDuration: 0.1, thenDragTo: end)
200
+ case .left:
201
+ right.press(forDuration: 0.1, thenDragTo: left)
202
+ case .right:
203
+ left.press(forDuration: 0.1, thenDragTo: right)
204
+ }
205
+ }
206
+
207
+ private func performTvRemoteSwipeIfAvailable(direction: SwipeDirection) -> Bool {
208
+ #if os(tvOS)
209
+ switch direction {
210
+ case .up:
211
+ XCUIRemote.shared.press(.up)
212
+ case .down:
213
+ XCUIRemote.shared.press(.down)
214
+ case .left:
215
+ XCUIRemote.shared.press(.left)
216
+ case .right:
217
+ XCUIRemote.shared.press(.right)
218
+ }
219
+ return true
220
+ #else
221
+ return false
222
+ #endif
223
+ }
224
+
225
+ func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
226
+ let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
227
+
228
+ // Use double-tap + drag gesture for reliable map zoom
229
+ // Zoom in (scale > 1): tap then drag UP
230
+ // Zoom out (scale < 1): tap then drag DOWN
231
+
232
+ // Determine center point (use provided x/y or screen center)
233
+ let centerX = x.map { $0 / target.frame.width } ?? 0.5
234
+ let centerY = y.map { $0 / target.frame.height } ?? 0.5
235
+ let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))
236
+
237
+ // Calculate drag distance based on scale (clamped to reasonable range)
238
+ // Larger scale = more drag distance
239
+ let dragAmount: CGFloat
240
+ if scale > 1.0 {
241
+ // Zoom in: drag up (negative Y direction in normalized coords)
242
+ dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
243
+ } else {
244
+ // Zoom out: drag down (positive Y direction)
245
+ dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
246
+ }
247
+
248
+ let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
249
+ let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))
250
+
251
+ // Tap first (first tap of double-tap)
252
+ center.tap()
253
+
254
+ // Immediately press and drag (second tap + drag)
255
+ center.press(forDuration: 0.05, thenDragTo: endPoint)
256
+ }
257
+
258
+ }
@@ -0,0 +1,174 @@
1
+ import XCTest
2
+ import UIKit
3
+
4
+ extension RunnerTests {
5
+ // MARK: - Recording
6
+
7
+ func captureRunnerFrame() -> UIImage? {
8
+ var image: UIImage?
9
+ let capture = {
10
+ let screenshot = XCUIScreen.main.screenshot()
11
+ image = screenshot.image
12
+ }
13
+ if Thread.isMainThread {
14
+ capture()
15
+ } else {
16
+ DispatchQueue.main.sync(execute: capture)
17
+ }
18
+ return image
19
+ }
20
+
21
+ func stopRecordingIfNeeded() {
22
+ guard let recorder = activeRecording else { return }
23
+ do {
24
+ try recorder.stop()
25
+ } catch {
26
+ NSLog("AGENT_DEVICE_RUNNER_RECORD_STOP_FAILED=%@", String(describing: error))
27
+ }
28
+ activeRecording = nil
29
+ }
30
+
31
+ func resolveRecordingOutPath(_ requestedOutPath: String) -> String {
32
+ let fileName = URL(fileURLWithPath: requestedOutPath).lastPathComponent
33
+ let fallbackName = "agent-device-recording-\(Int(Date().timeIntervalSince1970 * 1000)).mp4"
34
+ let safeFileName = fileName.isEmpty ? fallbackName : fileName
35
+ return (NSTemporaryDirectory() as NSString).appendingPathComponent(safeFileName)
36
+ }
37
+
38
+ // MARK: - Target Activation
39
+
40
+ func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
41
+ switch target.state {
42
+ case .unknown, .notRunning, .runningBackground, .runningBackgroundSuspended:
43
+ return true
44
+ default:
45
+ return false
46
+ }
47
+ }
48
+
49
+ func activateTarget(bundleId: String, reason: String) -> XCUIApplication {
50
+ let target = XCUIApplication(bundleIdentifier: bundleId)
51
+ NSLog(
52
+ "AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@",
53
+ bundleId,
54
+ target.state.rawValue,
55
+ reason
56
+ )
57
+ // activate avoids terminating and relaunching the target app
58
+ target.activate()
59
+ currentApp = target
60
+ currentBundleId = bundleId
61
+ needsFirstInteractionDelay = true
62
+ return target
63
+ }
64
+
65
+ func withTemporaryScrollIdleTimeoutIfSupported(
66
+ _ target: XCUIApplication,
67
+ operation: () -> Void
68
+ ) {
69
+ let setter = NSSelectorFromString("setWaitForIdleTimeout:")
70
+ guard target.responds(to: setter) else {
71
+ operation()
72
+ return
73
+ }
74
+ let previous = target.value(forKey: "waitForIdleTimeout") as? NSNumber
75
+ target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
76
+ defer {
77
+ if let previous {
78
+ target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
79
+ }
80
+ }
81
+ operation()
82
+ }
83
+
84
+ private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
85
+ guard
86
+ let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_IOS_INTERACTION_IDLE_TIMEOUT"],
87
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
88
+ else {
89
+ return scrollInteractionIdleTimeoutDefault
90
+ }
91
+ guard let parsed = Double(raw), parsed >= 0 else {
92
+ return scrollInteractionIdleTimeoutDefault
93
+ }
94
+ return min(parsed, 30)
95
+ }
96
+
97
+ func shouldRetryCommand(_ command: Command) -> Bool {
98
+ if RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
99
+ return false
100
+ }
101
+ return isReadOnlyCommand(command)
102
+ }
103
+
104
+ func shouldRetryException(_ command: Command, message: String) -> Bool {
105
+ guard shouldRetryCommand(command) else { return false }
106
+ let normalized = message.lowercased()
107
+ if normalized.contains("kaxerrorservernotfound") {
108
+ return true
109
+ }
110
+ if normalized.contains("main thread execution timed out") {
111
+ return true
112
+ }
113
+ if normalized.contains("timed out") && command.command == .snapshot {
114
+ return true
115
+ }
116
+ return false
117
+ }
118
+
119
+ // MARK: - Command Classification
120
+
121
+ func isReadOnlyCommand(_ command: Command) -> Bool {
122
+ switch command.command {
123
+ case .findText, .snapshot, .screenshot:
124
+ return true
125
+ case .alert:
126
+ let action = (command.action ?? "get").lowercased()
127
+ return action == "get"
128
+ default:
129
+ return false
130
+ }
131
+ }
132
+
133
+ func shouldRetryResponse(_ response: Response) -> Bool {
134
+ guard response.ok == false else { return false }
135
+ guard let message = response.error?.message.lowercased() else { return false }
136
+ return message.contains("is not available")
137
+ }
138
+
139
+ func isInteractionCommand(_ command: CommandType) -> Bool {
140
+ switch command {
141
+ case .tap, .longPress, .drag, .type, .swipe, .back, .appSwitcher, .pinch:
142
+ return true
143
+ default:
144
+ return false
145
+ }
146
+ }
147
+
148
+ func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
149
+ switch command {
150
+ case .shutdown, .recordStop, .screenshot:
151
+ return true
152
+ default:
153
+ return false
154
+ }
155
+ }
156
+
157
+ // MARK: - Interaction Stabilization
158
+
159
+ func applyInteractionStabilizationIfNeeded() {
160
+ if needsPostSnapshotInteractionDelay {
161
+ sleepFor(postSnapshotInteractionDelay)
162
+ needsPostSnapshotInteractionDelay = false
163
+ }
164
+ if needsFirstInteractionDelay {
165
+ sleepFor(firstInteractionAfterActivateDelay)
166
+ needsFirstInteractionDelay = false
167
+ }
168
+ }
169
+
170
+ func sleepFor(_ delay: TimeInterval) {
171
+ guard delay > 0 else { return }
172
+ usleep(useconds_t(delay * 1_000_000))
173
+ }
174
+ }
@@ -0,0 +1,121 @@
1
+ // MARK: - Wire Models
2
+
3
+ enum CommandType: String, Codable {
4
+ case tap
5
+ case tapSeries
6
+ case longPress
7
+ case drag
8
+ case dragSeries
9
+ case type
10
+ case swipe
11
+ case findText
12
+ case snapshot
13
+ case screenshot
14
+ case back
15
+ case home
16
+ case appSwitcher
17
+ case alert
18
+ case pinch
19
+ case recordStart
20
+ case recordStop
21
+ case shutdown
22
+ }
23
+
24
+ enum SwipeDirection: String, Codable {
25
+ case up
26
+ case down
27
+ case left
28
+ case right
29
+ }
30
+
31
+ struct Command: Codable {
32
+ let command: CommandType
33
+ let appBundleId: String?
34
+ let text: String?
35
+ let clearFirst: Bool?
36
+ let action: String?
37
+ let x: Double?
38
+ let y: Double?
39
+ let count: Double?
40
+ let intervalMs: Double?
41
+ let doubleTap: Bool?
42
+ let pauseMs: Double?
43
+ let pattern: String?
44
+ let x2: Double?
45
+ let y2: Double?
46
+ let durationMs: Double?
47
+ let direction: SwipeDirection?
48
+ let scale: Double?
49
+ let outPath: String?
50
+ let fps: Int?
51
+ let interactiveOnly: Bool?
52
+ let compact: Bool?
53
+ let depth: Int?
54
+ let scope: String?
55
+ let raw: Bool?
56
+ }
57
+
58
+ struct Response: Codable {
59
+ let ok: Bool
60
+ let data: DataPayload?
61
+ let error: ErrorPayload?
62
+
63
+ init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) {
64
+ self.ok = ok
65
+ self.data = data
66
+ self.error = error
67
+ }
68
+ }
69
+
70
+ struct DataPayload: Codable {
71
+ let message: String?
72
+ let found: Bool?
73
+ let items: [String]?
74
+ let nodes: [SnapshotNode]?
75
+ let truncated: Bool?
76
+
77
+ init(
78
+ message: String? = nil,
79
+ found: Bool? = nil,
80
+ items: [String]? = nil,
81
+ nodes: [SnapshotNode]? = nil,
82
+ truncated: Bool? = nil
83
+ ) {
84
+ self.message = message
85
+ self.found = found
86
+ self.items = items
87
+ self.nodes = nodes
88
+ self.truncated = truncated
89
+ }
90
+ }
91
+
92
+ struct ErrorPayload: Codable {
93
+ let message: String
94
+ }
95
+
96
+ struct SnapshotRect: Codable {
97
+ let x: Double
98
+ let y: Double
99
+ let width: Double
100
+ let height: Double
101
+ }
102
+
103
+ struct SnapshotNode: Codable {
104
+ let index: Int
105
+ let type: String
106
+ let label: String?
107
+ let identifier: String?
108
+ let value: String?
109
+ let rect: SnapshotRect
110
+ let enabled: Bool
111
+ let hittable: Bool
112
+ let depth: Int
113
+ }
114
+
115
+ struct SnapshotOptions {
116
+ let interactiveOnly: Bool
117
+ let compact: Bool
118
+ let depth: Int?
119
+ let scope: String?
120
+ let raw: Bool
121
+ }