agent-device 0.7.4 → 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,381 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Main Thread Dispatch
5
+
6
+ func execute(command: Command) throws -> Response {
7
+ if Thread.isMainThread {
8
+ return try executeOnMainSafely(command: command)
9
+ }
10
+ var result: Result<Response, Error>?
11
+ let semaphore = DispatchSemaphore(value: 0)
12
+ DispatchQueue.main.async {
13
+ do {
14
+ result = .success(try self.executeOnMainSafely(command: command))
15
+ } catch {
16
+ result = .failure(error)
17
+ }
18
+ semaphore.signal()
19
+ }
20
+ let waitResult = semaphore.wait(timeout: .now() + mainThreadExecutionTimeout)
21
+ if waitResult == .timedOut {
22
+ // The main queue work may still be running; we stop waiting and report timeout.
23
+ throw NSError(
24
+ domain: RunnerErrorDomain.general,
25
+ code: RunnerErrorCode.mainThreadExecutionTimedOut,
26
+ userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
27
+ )
28
+ }
29
+ switch result {
30
+ case .success(let response):
31
+ return response
32
+ case .failure(let error):
33
+ throw error
34
+ case .none:
35
+ throw NSError(
36
+ domain: RunnerErrorDomain.general,
37
+ code: RunnerErrorCode.noResponseFromMainThread,
38
+ userInfo: [NSLocalizedDescriptionKey: "no response from main thread"]
39
+ )
40
+ }
41
+ }
42
+
43
+ // MARK: - Command Handling
44
+
45
+ private func executeOnMainSafely(command: Command) throws -> Response {
46
+ var hasRetried = false
47
+ while true {
48
+ var response: Response?
49
+ var swiftError: Error?
50
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
51
+ do {
52
+ response = try self.executeOnMain(command: command)
53
+ } catch {
54
+ swiftError = error
55
+ }
56
+ })
57
+
58
+ if let exceptionMessage {
59
+ currentApp = nil
60
+ currentBundleId = nil
61
+ if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
62
+ NSLog(
63
+ "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
64
+ command.command.rawValue
65
+ )
66
+ hasRetried = true
67
+ sleepFor(retryCooldown)
68
+ continue
69
+ }
70
+ throw NSError(
71
+ domain: RunnerErrorDomain.exception,
72
+ code: RunnerErrorCode.objcException,
73
+ userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
74
+ )
75
+ }
76
+ if let swiftError {
77
+ throw swiftError
78
+ }
79
+ guard let response else {
80
+ throw NSError(
81
+ domain: RunnerErrorDomain.general,
82
+ code: RunnerErrorCode.commandReturnedNoResponse,
83
+ userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
84
+ )
85
+ }
86
+ if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
87
+ NSLog(
88
+ "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
89
+ command.command.rawValue
90
+ )
91
+ hasRetried = true
92
+ currentApp = nil
93
+ currentBundleId = nil
94
+ sleepFor(retryCooldown)
95
+ continue
96
+ }
97
+ return response
98
+ }
99
+ }
100
+
101
+ private func executeOnMain(command: Command) throws -> Response {
102
+ var activeApp = currentApp ?? app
103
+ if !isRunnerLifecycleCommand(command.command) {
104
+ let normalizedBundleId = command.appBundleId?
105
+ .trimmingCharacters(in: .whitespacesAndNewlines)
106
+ let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
107
+ if let bundleId = requestedBundleId {
108
+ if currentBundleId != bundleId || currentApp == nil {
109
+ _ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
110
+ }
111
+ } else {
112
+ // Do not reuse stale bundle targets when the caller does not explicitly request one.
113
+ currentApp = nil
114
+ currentBundleId = nil
115
+ }
116
+
117
+ activeApp = currentApp ?? app
118
+ if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
119
+ activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
120
+ } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
121
+ app.activate()
122
+ activeApp = app
123
+ }
124
+
125
+ if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
126
+ if let bundleId = requestedBundleId {
127
+ activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
128
+ guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
129
+ return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
130
+ }
131
+ } else {
132
+ return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
133
+ }
134
+ }
135
+
136
+ if isInteractionCommand(command.command) {
137
+ if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
138
+ activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
139
+ } else if requestedBundleId == nil, activeApp.state != .runningForeground {
140
+ app.activate()
141
+ activeApp = app
142
+ }
143
+ if !activeApp.waitForExistence(timeout: 2) {
144
+ if let bundleId = requestedBundleId {
145
+ return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
146
+ }
147
+ return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
148
+ }
149
+ applyInteractionStabilizationIfNeeded()
150
+ }
151
+ }
152
+
153
+ switch command.command {
154
+ case .shutdown:
155
+ stopRecordingIfNeeded()
156
+ return Response(ok: true, data: DataPayload(message: "shutdown"))
157
+ case .recordStart:
158
+ guard
159
+ let requestedOutPath = command.outPath?.trimmingCharacters(in: .whitespacesAndNewlines),
160
+ !requestedOutPath.isEmpty
161
+ else {
162
+ return Response(ok: false, error: ErrorPayload(message: "recordStart requires outPath"))
163
+ }
164
+ let hasAppBundleId = !(command.appBundleId?
165
+ .trimmingCharacters(in: .whitespacesAndNewlines)
166
+ .isEmpty ?? true)
167
+ guard hasAppBundleId else {
168
+ return Response(ok: false, error: ErrorPayload(message: "recordStart requires appBundleId"))
169
+ }
170
+ if activeRecording != nil {
171
+ return Response(ok: false, error: ErrorPayload(message: "recording already in progress"))
172
+ }
173
+ if let requestedFps = command.fps, (requestedFps < minRecordingFps || requestedFps > maxRecordingFps) {
174
+ return Response(ok: false, error: ErrorPayload(message: "recordStart fps must be between \(minRecordingFps) and \(maxRecordingFps)"))
175
+ }
176
+ do {
177
+ let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
178
+ let fpsLabel = command.fps.map(String.init) ?? "max"
179
+ NSLog(
180
+ "AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",
181
+ requestedOutPath,
182
+ resolvedOutPath,
183
+ fpsLabel
184
+ )
185
+ let recorder = ScreenRecorder(outputPath: resolvedOutPath, fps: command.fps.map { Int32($0) })
186
+ try recorder.start { [weak self] in
187
+ return self?.captureRunnerFrame()
188
+ }
189
+ activeRecording = recorder
190
+ return Response(ok: true, data: DataPayload(message: "recording started"))
191
+ } catch {
192
+ activeRecording = nil
193
+ return Response(ok: false, error: ErrorPayload(message: "failed to start recording: \(error.localizedDescription)"))
194
+ }
195
+ case .recordStop:
196
+ guard let recorder = activeRecording else {
197
+ return Response(ok: false, error: ErrorPayload(message: "no active recording"))
198
+ }
199
+ do {
200
+ try recorder.stop()
201
+ activeRecording = nil
202
+ return Response(ok: true, data: DataPayload(message: "recording stopped"))
203
+ } catch {
204
+ activeRecording = nil
205
+ return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
206
+ }
207
+ case .tap:
208
+ if let text = command.text {
209
+ if let element = findElement(app: activeApp, text: text) {
210
+ element.tap()
211
+ return Response(ok: true, data: DataPayload(message: "tapped"))
212
+ }
213
+ return Response(ok: false, error: ErrorPayload(message: "element not found"))
214
+ }
215
+ if let x = command.x, let y = command.y {
216
+ tapAt(app: activeApp, x: x, y: y)
217
+ return Response(ok: true, data: DataPayload(message: "tapped"))
218
+ }
219
+ return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
220
+ case .tapSeries:
221
+ guard let x = command.x, let y = command.y else {
222
+ return Response(ok: false, error: ErrorPayload(message: "tapSeries requires x and y"))
223
+ }
224
+ let count = max(Int(command.count ?? 1), 1)
225
+ let intervalMs = max(command.intervalMs ?? 0, 0)
226
+ let doubleTap = command.doubleTap ?? false
227
+ if doubleTap {
228
+ runSeries(count: count, pauseMs: intervalMs) { _ in
229
+ doubleTapAt(app: activeApp, x: x, y: y)
230
+ }
231
+ return Response(ok: true, data: DataPayload(message: "tap series"))
232
+ }
233
+ runSeries(count: count, pauseMs: intervalMs) { _ in
234
+ tapAt(app: activeApp, x: x, y: y)
235
+ }
236
+ return Response(ok: true, data: DataPayload(message: "tap series"))
237
+ case .longPress:
238
+ guard let x = command.x, let y = command.y else {
239
+ return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
240
+ }
241
+ let duration = (command.durationMs ?? 800) / 1000.0
242
+ longPressAt(app: activeApp, x: x, y: y, duration: duration)
243
+ return Response(ok: true, data: DataPayload(message: "long pressed"))
244
+ case .drag:
245
+ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
246
+ return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
247
+ }
248
+ let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
249
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
250
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
251
+ }
252
+ return Response(ok: true, data: DataPayload(message: "dragged"))
253
+ case .dragSeries:
254
+ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
255
+ return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
256
+ }
257
+ let count = max(Int(command.count ?? 1), 1)
258
+ let pauseMs = max(command.pauseMs ?? 0, 0)
259
+ let pattern = command.pattern ?? "one-way"
260
+ if pattern != "one-way" && pattern != "ping-pong" {
261
+ return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
262
+ }
263
+ let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
264
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
265
+ runSeries(count: count, pauseMs: pauseMs) { idx in
266
+ let reverse = pattern == "ping-pong" && (idx % 2 == 1)
267
+ if reverse {
268
+ dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
269
+ } else {
270
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
271
+ }
272
+ }
273
+ }
274
+ return Response(ok: true, data: DataPayload(message: "drag series"))
275
+ case .type:
276
+ guard let text = command.text else {
277
+ return Response(ok: false, error: ErrorPayload(message: "type requires text"))
278
+ }
279
+ if command.clearFirst == true {
280
+ guard let focused = focusedTextInput(app: activeApp) else {
281
+ return Response(ok: false, error: ErrorPayload(message: "no focused text input to clear"))
282
+ }
283
+ clearTextInput(focused)
284
+ focused.typeText(text)
285
+ return Response(ok: true, data: DataPayload(message: "typed"))
286
+ }
287
+ if let focused = focusedTextInput(app: activeApp) {
288
+ focused.typeText(text)
289
+ } else {
290
+ activeApp.typeText(text)
291
+ }
292
+ return Response(ok: true, data: DataPayload(message: "typed"))
293
+ case .swipe:
294
+ guard let direction = command.direction else {
295
+ return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
296
+ }
297
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
298
+ swipe(app: activeApp, direction: direction)
299
+ }
300
+ return Response(ok: true, data: DataPayload(message: "swiped"))
301
+ case .findText:
302
+ guard let text = command.text else {
303
+ return Response(ok: false, error: ErrorPayload(message: "findText requires text"))
304
+ }
305
+ let found = findElement(app: activeApp, text: text) != nil
306
+ return Response(ok: true, data: DataPayload(found: found))
307
+ case .snapshot:
308
+ let options = SnapshotOptions(
309
+ interactiveOnly: command.interactiveOnly ?? false,
310
+ compact: command.compact ?? false,
311
+ depth: command.depth,
312
+ scope: command.scope,
313
+ raw: command.raw ?? false,
314
+ )
315
+ if options.raw {
316
+ needsPostSnapshotInteractionDelay = true
317
+ return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
318
+ }
319
+ needsPostSnapshotInteractionDelay = true
320
+ return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
321
+ case .screenshot:
322
+ // If a target app bundle ID is provided, activate it first so the screenshot
323
+ // captures the target app rather than the AgentDeviceRunner itself.
324
+ if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
325
+ let targetApp = XCUIApplication(bundleIdentifier: bundleId)
326
+ targetApp.activate()
327
+ // Brief wait for the app transition animation to complete
328
+ Thread.sleep(forTimeInterval: 0.5)
329
+ }
330
+ let screenshot = XCUIScreen.main.screenshot()
331
+ guard let pngData = screenshot.image.pngData() else {
332
+ return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
333
+ }
334
+ let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
335
+ let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
336
+ do {
337
+ try pngData.write(to: URL(fileURLWithPath: filePath))
338
+ } catch {
339
+ return Response(ok: false, error: ErrorPayload(message: "Failed to write screenshot: \(error.localizedDescription)"))
340
+ }
341
+ // Return path relative to app container root (tmp/ maps to NSTemporaryDirectory)
342
+ return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)"))
343
+ case .back:
344
+ if tapNavigationBack(app: activeApp) {
345
+ return Response(ok: true, data: DataPayload(message: "back"))
346
+ }
347
+ performBackGesture(app: activeApp)
348
+ return Response(ok: true, data: DataPayload(message: "back"))
349
+ case .home:
350
+ pressHomeButton()
351
+ return Response(ok: true, data: DataPayload(message: "home"))
352
+ case .appSwitcher:
353
+ performAppSwitcherGesture(app: activeApp)
354
+ return Response(ok: true, data: DataPayload(message: "appSwitcher"))
355
+ case .alert:
356
+ let action = (command.action ?? "get").lowercased()
357
+ let alert = activeApp.alerts.firstMatch
358
+ if !alert.exists {
359
+ return Response(ok: false, error: ErrorPayload(message: "alert not found"))
360
+ }
361
+ if action == "accept" {
362
+ let button = alert.buttons.allElementsBoundByIndex.first
363
+ button?.tap()
364
+ return Response(ok: true, data: DataPayload(message: "accepted"))
365
+ }
366
+ if action == "dismiss" {
367
+ let button = alert.buttons.allElementsBoundByIndex.last
368
+ button?.tap()
369
+ return Response(ok: true, data: DataPayload(message: "dismissed"))
370
+ }
371
+ let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
372
+ return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
373
+ case .pinch:
374
+ guard let scale = command.scale, scale > 0 else {
375
+ return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
376
+ }
377
+ pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
378
+ return Response(ok: true, data: DataPayload(message: "pinched"))
379
+ }
380
+ }
381
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+
3
+ // MARK: - Environment
4
+
5
+ enum RunnerEnv {
6
+ static func resolvePort() -> UInt16 {
7
+ if let env = ProcessInfo.processInfo.environment["AGENT_DEVICE_RUNNER_PORT"], let port = UInt16(env) {
8
+ return port
9
+ }
10
+ for arg in CommandLine.arguments {
11
+ if arg.hasPrefix("AGENT_DEVICE_RUNNER_PORT=") {
12
+ let value = arg.replacingOccurrences(of: "AGENT_DEVICE_RUNNER_PORT=", with: "")
13
+ if let port = UInt16(value) { return port }
14
+ }
15
+ }
16
+ return 0
17
+ }
18
+
19
+ static func isTruthy(_ name: String) -> Bool {
20
+ guard let raw = ProcessInfo.processInfo.environment[name] else {
21
+ return false
22
+ }
23
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
24
+ case "1", "true", "yes", "on":
25
+ return true
26
+ default:
27
+ return false
28
+ }
29
+ }
30
+ }
@@ -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
+ }