agent-device 0.14.8 → 0.15.0

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 (49) hide show
  1. package/README.md +8 -6
  2. package/android-snapshot-helper/README.md +4 -2
  3. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.apk → agent-device-android-snapshot-helper-0.15.0.apk} +0 -0
  4. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.0.apk.sha256 +1 -0
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.manifest.json → agent-device-android-snapshot-helper-0.15.0.manifest.json} +6 -6
  6. package/dist/src/1769.js +7 -0
  7. package/dist/src/2151.js +429 -0
  8. package/dist/src/221.js +4 -4
  9. package/dist/src/2842.js +1 -0
  10. package/dist/src/3572.js +1 -0
  11. package/dist/src/4057.js +1 -1
  12. package/dist/src/840.js +2 -0
  13. package/dist/src/9542.js +2 -2
  14. package/dist/src/9639.js +2 -2
  15. package/dist/src/9818.js +1 -1
  16. package/dist/src/android-adb.d.ts +49 -11
  17. package/dist/src/android-adb.js +1 -1
  18. package/dist/src/android-snapshot-helper.d.ts +35 -2
  19. package/dist/src/cli.js +60 -57
  20. package/dist/src/contracts.d.ts +2 -0
  21. package/dist/src/finders.d.ts +2 -0
  22. package/dist/src/index.d.ts +25 -22
  23. package/dist/src/internal/companion-tunnel.js +1 -1
  24. package/dist/src/internal/daemon.js +51 -23
  25. package/dist/src/remote-config.d.ts +17 -14
  26. package/dist/src/selectors.d.ts +3 -0
  27. package/dist/src/server.js +2 -20
  28. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
  29. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +210 -56
  30. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +890 -99
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +94 -7
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +8 -0
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +24 -0
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +2 -0
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +185 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
  38. package/package.json +25 -11
  39. package/server.json +3 -3
  40. package/skills/agent-device/SKILL.md +6 -1
  41. package/skills/dogfood/SKILL.md +3 -1
  42. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.8.apk.sha256 +0 -1
  43. package/dist/src/180.js +0 -1
  44. package/dist/src/6108.js +0 -26
  45. package/dist/src/6642.js +0 -1
  46. package/dist/src/7462.js +0 -1
  47. package/dist/src/8809.js +0 -8
  48. package/dist/src/command-schema.js +0 -381
  49. package/skills/react-devtools/SKILL.md +0 -48
@@ -17,6 +17,60 @@ extension RunnerTests {
17
17
  let referenceHeight: Double
18
18
  }
19
19
 
20
+ struct DragPoints {
21
+ let x: Double
22
+ let y: Double
23
+ let x2: Double
24
+ let y2: Double
25
+ }
26
+
27
+ struct SelectorElementMatch {
28
+ let element: XCUIElement?
29
+ let isAmbiguous: Bool
30
+ }
31
+
32
+ enum TextTypingRepairMode {
33
+ case none
34
+ case append
35
+ case replacement
36
+ }
37
+
38
+ enum TextEntryTiming {
39
+ static let focusTimeout: TimeInterval = 0.4
40
+ static let repairReadinessTimeout: TimeInterval = 1.0
41
+ static let readinessTimeout: TimeInterval = 2.0
42
+ static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
43
+ static let pollInterval: TimeInterval = 0.02
44
+ static let warmupValueTimeout: TimeInterval = 0.4
45
+ static let verificationStabilityWindow: TimeInterval = 0.2
46
+ }
47
+
48
+ struct TextEntryResult {
49
+ let verified: Bool?
50
+ let repaired: Bool
51
+ let expectedText: String?
52
+ let observedText: String?
53
+ }
54
+
55
+ struct TextEntryTarget {
56
+ let element: XCUIElement?
57
+ let refreshPoint: CGPoint?
58
+ let prefersFocusedElement: Bool
59
+
60
+ func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
61
+ guard let nextElement else {
62
+ return self
63
+ }
64
+ let frame = nextElement.frame
65
+ let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
66
+ return TextEntryTarget(
67
+ element: nextElement,
68
+ refreshPoint: point,
69
+ prefersFocusedElement: prefersFocusedElement
70
+ )
71
+ }
72
+ }
73
+
20
74
  // MARK: - Navigation Gestures
21
75
 
22
76
  func tapInAppBackControl(app: XCUIApplication) -> Bool {
@@ -26,6 +80,9 @@ extension RunnerTests {
26
80
  return true
27
81
  }
28
82
  return false
83
+ #elseif os(tvOS)
84
+ _ = pressTvRemote(.menu)
85
+ return true
29
86
  #else
30
87
  let buttons = app.navigationBars.buttons.allElementsBoundByIndex
31
88
  if let back = buttons.first(where: { $0.isHittable }) {
@@ -37,20 +94,26 @@ extension RunnerTests {
37
94
  }
38
95
 
39
96
  func performBackGesture(app: XCUIApplication) {
40
- if pressTvRemoteMenuIfAvailable() {
97
+ if pressTvRemote(.menu) {
41
98
  return
42
99
  }
100
+ performCoordinateBackGesture(app: app)
101
+ }
102
+
103
+ private func performCoordinateBackGesture(app: XCUIApplication) {
104
+ #if !os(tvOS)
43
105
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
44
106
  let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
45
107
  let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
46
108
  start.press(forDuration: 0.05, thenDragTo: end)
109
+ #endif
47
110
  }
48
111
 
49
112
  func performSystemBackAction(app: XCUIApplication) -> Bool {
50
113
  #if os(macOS)
51
114
  return false
52
115
  #else
53
- if pressTvRemoteMenuIfAvailable() {
116
+ if pressTvRemote(.menu) {
54
117
  return true
55
118
  }
56
119
  performBackGesture(app: app)
@@ -59,20 +122,28 @@ extension RunnerTests {
59
122
  }
60
123
 
61
124
  func performAppSwitcherGesture(app: XCUIApplication) {
62
- if performTvRemoteAppSwitcherIfAvailable() {
125
+ if pressTvRemote(.home) {
126
+ sleepFor(resolveTvRemoteDoublePressDelay())
127
+ _ = pressTvRemote(.home)
63
128
  return
64
129
  }
130
+ performCoordinateAppSwitcherGesture(app: app)
131
+ }
132
+
133
+ private func performCoordinateAppSwitcherGesture(app: XCUIApplication) {
134
+ #if !os(tvOS)
65
135
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
66
136
  let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
67
137
  let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
68
138
  start.press(forDuration: 0.6, thenDragTo: end)
139
+ #endif
69
140
  }
70
141
 
71
142
  func pressHomeButton() {
72
143
  #if os(macOS)
73
144
  return
74
145
  #else
75
- if pressTvRemoteHomeIfAvailable() {
146
+ if pressTvRemote(.home) {
76
147
  return
77
148
  }
78
149
  XCUIDevice.shared.press(.home)
@@ -80,7 +151,7 @@ extension RunnerTests {
80
151
  }
81
152
 
82
153
  func rotateDevice(to orientationName: String) -> Bool {
83
- #if os(macOS)
154
+ #if os(macOS) || os(tvOS)
84
155
  return false
85
156
  #else
86
157
  switch orientationName {
@@ -100,52 +171,82 @@ extension RunnerTests {
100
171
  #endif
101
172
  }
102
173
 
103
- private func pressTvRemoteMenuIfAvailable() -> Bool {
104
- #if os(tvOS)
105
- XCUIRemote.shared.press(.menu)
106
- return true
107
- #else
108
- return false
109
- #endif
174
+ func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
175
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
176
+ let element = app.descendants(matching: .any).matching(predicate).firstMatch
177
+ return element.exists ? element : nil
110
178
  }
111
179
 
112
- private func pressTvRemoteHomeIfAvailable() -> Bool {
113
- #if os(tvOS)
114
- XCUIRemote.shared.press(.home)
115
- return true
116
- #else
117
- return false
118
- #endif
119
- }
180
+ func findElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> SelectorElementMatch {
181
+ let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
182
+ guard !value.isEmpty else {
183
+ return SelectorElementMatch(element: nil, isAmbiguous: false)
184
+ }
185
+ let predicate: NSPredicate
186
+ switch selectorKey {
187
+ case "id":
188
+ predicate = NSPredicate(format: "identifier ==[c] %@", value)
189
+ case "label":
190
+ predicate = NSPredicate(format: "label ==[c] %@", value)
191
+ case "value":
192
+ predicate = NSPredicate(format: "value ==[c] %@", value)
193
+ case "text":
194
+ predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value)
195
+ default:
196
+ return SelectorElementMatch(element: nil, isAmbiguous: false)
197
+ }
120
198
 
121
- private func performTvRemoteAppSwitcherIfAvailable() -> Bool {
122
- #if os(tvOS)
123
- XCUIRemote.shared.press(.home)
124
- sleepFor(resolveTvRemoteDoublePressDelay())
125
- XCUIRemote.shared.press(.home)
126
- return true
127
- #else
128
- return false
129
- #endif
199
+ var matchedElement: XCUIElement?
200
+ let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
201
+ for element in matches where element.exists {
202
+ guard element.isHittable else {
203
+ continue
204
+ }
205
+ guard matchedElement == nil else {
206
+ return SelectorElementMatch(element: nil, isAmbiguous: true)
207
+ }
208
+ matchedElement = element
209
+ }
210
+ return SelectorElementMatch(element: matchedElement, isAmbiguous: false)
130
211
  }
131
212
 
132
- private func resolveTvRemoteDoublePressDelay() -> TimeInterval {
133
- guard
134
- let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_TV_REMOTE_DOUBLE_PRESS_DELAY_MS"],
135
- !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
136
- else {
137
- return tvRemoteDoublePressDelayDefault
213
+ func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
214
+ let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
215
+ if match.isAmbiguous {
216
+ return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
138
217
  }
139
- guard let parsedMs = Double(raw), parsedMs >= 0 else {
140
- return tvRemoteDoublePressDelayDefault
218
+ guard let element = match.element else {
219
+ return Response(ok: true, data: DataPayload(found: false, nodes: []))
141
220
  }
142
- return min(parsedMs, 1000) / 1000.0
143
- }
144
221
 
145
- func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
146
- let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
147
- let element = app.descendants(matching: .any).matching(predicate).firstMatch
148
- return element.exists ? element : nil
222
+ let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
223
+ let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
224
+ let valueText = String(describing: element.value ?? "")
225
+ .trimmingCharacters(in: .whitespacesAndNewlines)
226
+ let node = SnapshotNode(
227
+ index: 0,
228
+ type: elementTypeName(element.elementType),
229
+ label: label.isEmpty ? nil : label,
230
+ identifier: identifier.isEmpty ? nil : identifier,
231
+ value: valueText.isEmpty ? nil : valueText,
232
+ rect: snapshotRect(from: element.frame),
233
+ enabled: element.isEnabled,
234
+ focused: nil,
235
+ selected: element.isSelected ? true : nil,
236
+ hittable: element.isHittable,
237
+ depth: 0,
238
+ parentIndex: nil,
239
+ hiddenContentAbove: nil,
240
+ hiddenContentBelow: nil
241
+ )
242
+ return Response(
243
+ ok: true,
244
+ data: DataPayload(
245
+ text: readableText(for: element),
246
+ found: true,
247
+ nodes: [node]
248
+ )
249
+ )
149
250
  }
150
251
 
151
252
  func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
@@ -183,7 +284,9 @@ extension RunnerTests {
183
284
  }
184
285
 
185
286
  func clearTextInput(_ element: XCUIElement) {
287
+ #if !os(tvOS)
186
288
  moveCaretToEnd(element: element)
289
+ #endif
187
290
  let count = estimatedDeleteCount(for: element)
188
291
  let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
189
292
  element.typeText(deletes)
@@ -257,9 +360,458 @@ extension RunnerTests {
257
360
  return focused
258
361
  }
259
362
 
363
+ func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
364
+ #if os(tvOS)
365
+ return target
366
+ #else
367
+ let latest = target
368
+ let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
369
+ while Date() < deadline {
370
+ if let focused = focusedTextInput(app: app) {
371
+ return focused
372
+ }
373
+ sleepFor(TextEntryTiming.pollInterval)
374
+ }
375
+ return latest
376
+ #endif
377
+ }
378
+
379
+ func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
380
+ guard let x, let y else {
381
+ let focused = waitForTextEntryReadiness(
382
+ app: app,
383
+ target: TextEntryTarget(
384
+ element: focusedTextInput(app: app),
385
+ refreshPoint: nil,
386
+ prefersFocusedElement: true
387
+ )
388
+ )
389
+ return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
390
+ }
391
+
392
+ let target = textInputAt(app: app, x: x, y: y)
393
+ let requestedPoint = CGPoint(x: x, y: y)
394
+ if let target {
395
+ let frame = target.frame
396
+ if !frame.isEmpty {
397
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
398
+ } else {
399
+ _ = tapAt(app: app, x: x, y: y)
400
+ }
401
+ } else {
402
+ _ = tapAt(app: app, x: x, y: y)
403
+ }
404
+ let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
405
+ let element = waitForTextEntryReadiness(
406
+ app: app,
407
+ target: TextEntryTarget(
408
+ element: stabilized ?? target,
409
+ refreshPoint: requestedPoint,
410
+ prefersFocusedElement: false
411
+ )
412
+ ) ?? stabilized ?? target
413
+ return TextEntryTarget(
414
+ element: element,
415
+ refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
416
+ prefersFocusedElement: false
417
+ )
418
+ }
419
+
420
+ func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
421
+ switch command.textEntryMode {
422
+ case "append":
423
+ return .append
424
+ case "replace":
425
+ return .replacement
426
+ default:
427
+ return command.clearFirst == true ? .replacement : .none
428
+ }
429
+ }
430
+
431
+ func typeTextReliably(
432
+ app: XCUIApplication,
433
+ target: TextEntryTarget,
434
+ text: String,
435
+ delaySeconds: Double,
436
+ repairMode: TextTypingRepairMode = .none
437
+ ) -> TextEntryResult {
438
+ guard !text.isEmpty else {
439
+ return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
440
+ }
441
+ var activeTarget = target
442
+ let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
443
+ activeTarget = activeTarget.withElement(initialTarget)
444
+ let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
445
+ let initialText = repairMode == .append ? currentText : nil
446
+ let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
447
+
448
+ if repairMode == .replacement {
449
+ guard let replacementTarget = initialTarget else {
450
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
451
+ }
452
+ if currentText == nil || currentText?.isEmpty == false {
453
+ clearTextInput(replacementTarget)
454
+ activeTarget = activeTarget.withElement(replacementTarget)
455
+ }
456
+ }
457
+
458
+ func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
459
+ if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
460
+ app.typeText(value)
461
+ return currentTarget
462
+ } else {
463
+ app.typeText(value)
464
+ return resolveTextEntryElement(app: app, target: activeTarget)
465
+ }
466
+ }
467
+
468
+ func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
469
+ guard let expectedValue else {
470
+ sleepFor(TextEntryTiming.pollInterval)
471
+ return
472
+ }
473
+ let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
474
+ while Date() < deadline {
475
+ if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
476
+ return
477
+ }
478
+ sleepFor(TextEntryTiming.pollInterval)
479
+ }
480
+ }
481
+
482
+ let characters = Array(text)
483
+ if delaySeconds > 0 && characters.count > 1 {
484
+ var typedTarget: XCUIElement?
485
+ for (index, character) in characters.enumerated() {
486
+ typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
487
+ if index + 1 < characters.count {
488
+ sleepFor(delaySeconds)
489
+ }
490
+ }
491
+ if repairMode == .none {
492
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
493
+ }
494
+ let repairResult = repairTextEntryIfNeeded(
495
+ app: app,
496
+ target: activeTarget.withElement(typedTarget),
497
+ expectedText: expectedText,
498
+ repairMode: repairMode
499
+ )
500
+ return verifyTextEntry(
501
+ app: app,
502
+ target: activeTarget.withElement(typedTarget),
503
+ expectedText: expectedText,
504
+ repaired: repairResult.repaired
505
+ )
506
+ }
507
+
508
+ let typedTarget: XCUIElement?
509
+ if repairMode != .none && characters.count > 1 {
510
+ let firstCharacter = String(characters[0])
511
+ var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
512
+ activeTarget = activeTarget.withElement(firstTypedTarget)
513
+ let warmupExpectedText = expectedTextEntryValue(
514
+ typedText: firstCharacter,
515
+ mode: repairMode,
516
+ initialText: initialText
517
+ )
518
+ waitForWarmupValue(warmupExpectedText, target: activeTarget)
519
+ let remainingText = String(characters.dropFirst())
520
+ firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
521
+ typedTarget = firstTypedTarget
522
+ } else {
523
+ typedTarget = typeIntoCurrentTarget(text)
524
+ }
525
+ if repairMode == .none {
526
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
527
+ }
528
+ let repairResult = repairTextEntryIfNeeded(
529
+ app: app,
530
+ target: activeTarget.withElement(typedTarget),
531
+ expectedText: expectedText,
532
+ repairMode: repairMode
533
+ )
534
+ return verifyTextEntry(
535
+ app: app,
536
+ target: activeTarget.withElement(typedTarget),
537
+ expectedText: expectedText,
538
+ repaired: repairResult.repaired
539
+ )
540
+ }
541
+
542
+ private func repairTextEntryIfNeeded(
543
+ app: XCUIApplication,
544
+ target: TextEntryTarget,
545
+ expectedText: String?,
546
+ repairMode: TextTypingRepairMode
547
+ ) -> TextEntryResult {
548
+ #if os(iOS)
549
+ guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
550
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
551
+ }
552
+ guard let expectedText else {
553
+ let observedText = editableTextValue(for: targetElement)
554
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
555
+ }
556
+ guard shouldRepairTextEntry(
557
+ app: app,
558
+ target: target,
559
+ expectedText: expectedText,
560
+ repairMode: repairMode
561
+ ) else {
562
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
563
+ }
564
+
565
+ guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
566
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
567
+ }
568
+ let observedText = editableTextValue(for: repairTarget) ?? ""
569
+ NSLog(
570
+ "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
571
+ expectedText.count,
572
+ observedText.count
573
+ )
574
+ clearTextInput(repairTarget)
575
+ app.typeText(expectedText)
576
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
577
+ #else
578
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
579
+ #endif
580
+ }
581
+
582
+ private func verifyTextEntry(
583
+ app: XCUIApplication,
584
+ target: TextEntryTarget,
585
+ expectedText: String?,
586
+ repaired: Bool
587
+ ) -> TextEntryResult {
588
+ let targetElement = resolveTextEntryElement(app: app, target: target)
589
+ guard let expectedText else {
590
+ return TextEntryResult(
591
+ verified: nil,
592
+ repaired: repaired,
593
+ expectedText: nil,
594
+ observedText: editableTextValue(for: targetElement)
595
+ )
596
+ }
597
+ guard let observedText = editableTextValue(for: targetElement) else {
598
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
599
+ }
600
+ guard observedText == expectedText else {
601
+ return TextEntryResult(
602
+ verified: false,
603
+ repaired: repaired,
604
+ expectedText: expectedText,
605
+ observedText: observedText
606
+ )
607
+ }
608
+ let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
609
+ var latestObservedText = observedText
610
+ while Date() < stableDeadline {
611
+ sleepFor(TextEntryTiming.pollInterval)
612
+ guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
613
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
614
+ }
615
+ latestObservedText = nextObservedText
616
+ guard nextObservedText == expectedText else {
617
+ return TextEntryResult(
618
+ verified: false,
619
+ repaired: repaired,
620
+ expectedText: expectedText,
621
+ observedText: nextObservedText
622
+ )
623
+ }
624
+ }
625
+ return TextEntryResult(
626
+ verified: true,
627
+ repaired: repaired,
628
+ expectedText: expectedText,
629
+ observedText: latestObservedText
630
+ )
631
+ }
632
+
633
+ private func expectedTextEntryValue(
634
+ typedText: String,
635
+ mode: TextTypingRepairMode,
636
+ initialText: String?
637
+ ) -> String? {
638
+ switch mode {
639
+ case .none:
640
+ return nil
641
+ case .append:
642
+ guard let initialText else {
643
+ return nil
644
+ }
645
+ return initialText + typedText
646
+ case .replacement:
647
+ return typedText
648
+ }
649
+ }
650
+
651
+ private func shouldRepairTextEntry(
652
+ app: XCUIApplication,
653
+ target: TextEntryTarget,
654
+ expectedText: String,
655
+ repairMode: TextTypingRepairMode
656
+ ) -> Bool {
657
+ #if os(iOS)
658
+ var latestObservedText: String?
659
+ let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
660
+ repeat {
661
+ guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
662
+ return false
663
+ }
664
+ if observedText == expectedText {
665
+ return false
666
+ }
667
+ latestObservedText = observedText
668
+ if !isRepairableTextEntryMismatch(
669
+ observedText: observedText,
670
+ expectedText: expectedText,
671
+ repairMode: repairMode
672
+ ) {
673
+ return false
674
+ }
675
+ sleepFor(TextEntryTiming.pollInterval)
676
+ } while Date() < deadline
677
+
678
+ guard let latestObservedText else {
679
+ return false
680
+ }
681
+ guard latestObservedText != expectedText else {
682
+ return false
683
+ }
684
+ return isRepairableTextEntryMismatch(
685
+ observedText: latestObservedText,
686
+ expectedText: expectedText,
687
+ repairMode: repairMode
688
+ )
689
+ #else
690
+ return false
691
+ #endif
692
+ }
693
+
694
+ private func isRepairableTextEntryMismatch(
695
+ observedText: String,
696
+ expectedText: String,
697
+ repairMode: TextTypingRepairMode
698
+ ) -> Bool {
699
+ guard observedText != expectedText else {
700
+ return false
701
+ }
702
+ if repairMode == .replacement {
703
+ return true
704
+ }
705
+ return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
706
+ observedText: observedText,
707
+ expectedText: expectedText
708
+ )
709
+ }
710
+
711
+ private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
712
+ guard observedText.count < expectedText.count else {
713
+ return false
714
+ }
715
+ let missingCharacterCount = expectedText.count - observedText.count
716
+ guard missingCharacterCount <= max(2, expectedText.count / 4) else {
717
+ return false
718
+ }
719
+ var expectedIndex = expectedText.startIndex
720
+ for character in observedText {
721
+ guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
722
+ return false
723
+ }
724
+ expectedIndex = expectedText.index(after: matchIndex)
725
+ }
726
+ return true
727
+ }
728
+
729
+ private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
730
+ if target.prefersFocusedElement {
731
+ if let focused = focusedTextInput(app: app) {
732
+ return focused
733
+ }
734
+ if let element = target.element, element.exists {
735
+ return element
736
+ }
737
+ } else {
738
+ if let element = target.element, element.exists {
739
+ return element
740
+ }
741
+ }
742
+ if let refreshPoint = target.refreshPoint,
743
+ let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
744
+ return refreshed
745
+ }
746
+ if let focused = focusedTextInput(app: app) {
747
+ return focused
748
+ }
749
+ return nil
750
+ }
751
+
752
+ private func waitForTextEntryReadiness(
753
+ app: XCUIApplication,
754
+ target: TextEntryTarget,
755
+ timeout: TimeInterval = TextEntryTiming.readinessTimeout
756
+ ) -> XCUIElement? {
757
+ #if os(iOS)
758
+ var latest = resolveTextEntryElement(app: app, target: target)
759
+ let deadline = Date().addingTimeInterval(timeout)
760
+ let hardwareKeyboardFallback = Date().addingTimeInterval(
761
+ min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
762
+ )
763
+ var sawSoftwareKeyboard = false
764
+ while Date() < deadline {
765
+ if let focused = focusedTextInput(app: app) {
766
+ latest = focused
767
+ if isKeyboardVisible(app: app) {
768
+ return focused
769
+ }
770
+ }
771
+ sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
772
+ if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
773
+ return latest
774
+ }
775
+ sleepFor(TextEntryTiming.pollInterval)
776
+ }
777
+ return focusedTextInput(app: app) ?? latest
778
+ #else
779
+ return resolveTextEntryElement(app: app, target: target)
780
+ #endif
781
+ }
782
+
783
+ private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
784
+ guard let element else {
785
+ return nil
786
+ }
787
+ let frame = element.frame
788
+ guard !frame.isEmpty else {
789
+ return nil
790
+ }
791
+ return CGPoint(x: frame.midX, y: frame.midY)
792
+ }
793
+
260
794
  func isKeyboardVisible(app: XCUIApplication) -> Bool {
261
- let keyboard = app.keyboards.firstMatch
262
- return keyboard.exists && !keyboard.frame.isEmpty
795
+ return visibleKeyboardFrame(app: app) != nil
796
+ }
797
+
798
+ private func keyboardElementExists(app: XCUIApplication) -> Bool {
799
+ #if os(iOS)
800
+ var exists = false
801
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
802
+ exists = app.keyboards.firstMatch.exists
803
+ })
804
+ if let exceptionMessage {
805
+ NSLog(
806
+ "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
807
+ exceptionMessage
808
+ )
809
+ return false
810
+ }
811
+ return exists
812
+ #else
813
+ return false
814
+ #endif
263
815
  }
264
816
 
265
817
  func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
@@ -268,6 +820,12 @@ extension RunnerTests {
268
820
  return (wasVisible: false, dismissed: false, visible: false)
269
821
  }
270
822
 
823
+ #if os(tvOS)
824
+ _ = pressTvRemote(.menu)
825
+ sleepFor(0.2)
826
+ let visible = isKeyboardVisible(app: app)
827
+ return (wasVisible: true, dismissed: !visible, visible: visible)
828
+ #else
271
829
  let keyboard = app.keyboards.firstMatch
272
830
  keyboard.swipeDown()
273
831
  sleepFor(0.2)
@@ -282,10 +840,16 @@ extension RunnerTests {
282
840
  }
283
841
 
284
842
  return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
843
+ #endif
285
844
  }
286
845
 
287
846
  private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
288
- let keyboardFrame = app.keyboards.firstMatch.frame
847
+ #if os(tvOS)
848
+ return false
849
+ #else
850
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
851
+ return false
852
+ }
289
853
  for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
290
854
  let candidates = [
291
855
  app.keyboards.buttons[label],
@@ -313,6 +877,7 @@ extension RunnerTests {
313
877
  }
314
878
  }
315
879
  return false
880
+ #endif
316
881
  }
317
882
 
318
883
  private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
@@ -324,6 +889,9 @@ extension RunnerTests {
324
889
  }
325
890
 
326
891
  private func moveCaretToEnd(element: XCUIElement) {
892
+ #if os(tvOS)
893
+ return
894
+ #else
327
895
  let frame = element.frame
328
896
  guard !frame.isEmpty else {
329
897
  element.tap()
@@ -334,15 +902,53 @@ extension RunnerTests {
334
902
  CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
335
903
  )
336
904
  target.tap()
905
+ #endif
337
906
  }
338
907
 
339
908
  private func estimatedDeleteCount(for element: XCUIElement) -> Int {
340
- let valueText = String(describing: element.value ?? "")
341
- .trimmingCharacters(in: .whitespacesAndNewlines)
909
+ let valueText = normalizedElementText(element.value)
342
910
  let base = valueText.isEmpty ? 24 : (valueText.count + 8)
343
911
  return max(24, min(120, base))
344
912
  }
345
913
 
914
+ private func normalizedElementText(_ value: Any?) -> String {
915
+ String(describing: value ?? "")
916
+ .trimmingCharacters(in: .whitespacesAndNewlines)
917
+ }
918
+
919
+ private func editableTextValue(
920
+ for element: XCUIElement?,
921
+ treatingPlaceholderAsEmpty: Bool = false
922
+ ) -> String? {
923
+ guard let element else {
924
+ return nil
925
+ }
926
+ switch element.elementType {
927
+ case .textField, .searchField, .textView:
928
+ let value = String(describing: element.value ?? "")
929
+ if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
930
+ return ""
931
+ }
932
+ return value
933
+ case .secureTextField:
934
+ return nil
935
+ default:
936
+ return nil
937
+ }
938
+ }
939
+
940
+ private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
941
+ let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
942
+ guard !normalizedValue.isEmpty else {
943
+ return false
944
+ }
945
+ guard let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines),
946
+ !placeholder.isEmpty else {
947
+ return false
948
+ }
949
+ return normalizedValue == placeholder
950
+ }
951
+
346
952
  private func readableText(for element: XCUIElement) -> String? {
347
953
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
348
954
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -379,49 +985,64 @@ extension RunnerTests {
379
985
  return element.exists ? element : nil
380
986
  }
381
987
 
382
- func tapAt(app: XCUIApplication, x: Double, y: Double) {
383
- let coordinate = interactionCoordinate(app: app, x: x, y: y)
384
- coordinate.tap()
988
+ func tapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
989
+ if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "tap") {
990
+ return outcome
991
+ }
992
+ return performCoordinateTap(app: app, x: x, y: y)
385
993
  }
386
994
 
387
995
  func mouseClickAt(app: XCUIApplication, x: Double, y: Double, button: String) throws {
996
+ #if os(macOS)
388
997
  let coordinate = interactionCoordinate(app: app, x: x, y: y)
389
- #if os(macOS)
390
- switch button {
391
- case "primary":
392
- coordinate.tap()
393
- case "secondary":
394
- coordinate.rightClick()
395
- case "middle":
396
- throw NSError(
397
- domain: "AgentDeviceRunner",
398
- code: 1,
399
- userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
400
- )
401
- default:
402
- throw NSError(
403
- domain: "AgentDeviceRunner",
404
- code: 1,
405
- userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
406
- )
407
- }
408
- #else
998
+ switch button {
999
+ case "primary":
1000
+ coordinate.tap()
1001
+ case "secondary":
1002
+ coordinate.rightClick()
1003
+ case "middle":
409
1004
  throw NSError(
410
1005
  domain: "AgentDeviceRunner",
411
1006
  code: 1,
412
- userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
1007
+ userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
413
1008
  )
414
- #endif
1009
+ default:
1010
+ throw NSError(
1011
+ domain: "AgentDeviceRunner",
1012
+ code: 1,
1013
+ userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
1014
+ )
1015
+ }
1016
+ #elseif os(tvOS)
1017
+ throw NSError(
1018
+ domain: "AgentDeviceRunner",
1019
+ code: 1,
1020
+ userInfo: [NSLocalizedDescriptionKey: "mouseClick is not supported on tvOS"]
1021
+ )
1022
+ #else
1023
+ throw NSError(
1024
+ domain: "AgentDeviceRunner",
1025
+ code: 1,
1026
+ userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
1027
+ )
1028
+ #endif
415
1029
  }
416
1030
 
417
- func doubleTapAt(app: XCUIApplication, x: Double, y: Double) {
418
- let coordinate = interactionCoordinate(app: app, x: x, y: y)
419
- coordinate.doubleTap()
1031
+ func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1032
+ if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
1033
+ guard case .performed = outcome else { return outcome }
1034
+ sleepFor(0.1)
1035
+ _ = pressTvRemote(.select)
1036
+ return .performed
1037
+ }
1038
+ return performCoordinateDoubleTap(app: app, x: x, y: y)
420
1039
  }
421
1040
 
422
- func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) {
423
- let coordinate = interactionCoordinate(app: app, x: x, y: y)
424
- coordinate.press(forDuration: duration)
1041
+ func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
1042
+ if let outcome = longSelectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), duration: duration) {
1043
+ return outcome
1044
+ }
1045
+ return performCoordinateLongPress(app: app, x: x, y: y, duration: duration)
425
1046
  }
426
1047
 
427
1048
  func dragAt(
@@ -431,10 +1052,79 @@ extension RunnerTests {
431
1052
  x2: Double,
432
1053
  y2: Double,
433
1054
  holdDuration: TimeInterval
434
- ) {
435
- let start = interactionCoordinate(app: app, x: x, y: y)
436
- let end = interactionCoordinate(app: app, x: x2, y: y2)
437
- start.press(forDuration: holdDuration, thenDragTo: end)
1055
+ ) -> RunnerInteractionOutcome {
1056
+ // tvOS has no coordinate drag. Preserve the direction as a focus move.
1057
+ let dx = x2 - x
1058
+ let dy = y2 - y
1059
+ let button: TvRemoteButton = abs(dx) > abs(dy)
1060
+ ? (dx > 0 ? .right : .left)
1061
+ : (dy > 0 ? .down : .up)
1062
+ if pressTvRemote(button) {
1063
+ return .performed
1064
+ }
1065
+ return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
1066
+ }
1067
+
1068
+ func keyboardAvoidingDragPoints(
1069
+ app: XCUIApplication,
1070
+ x: Double,
1071
+ y: Double,
1072
+ x2: Double,
1073
+ y2: Double
1074
+ ) -> DragPoints {
1075
+ let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
1076
+ #if os(iOS)
1077
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
1078
+ return original
1079
+ }
1080
+ let minX = min(x, x2)
1081
+ let minY = min(y, y2)
1082
+ let gestureBounds = CGRect(
1083
+ x: CGFloat(minX),
1084
+ y: CGFloat(minY),
1085
+ width: CGFloat(max(abs(x2 - x), 1)),
1086
+ height: CGFloat(max(abs(y2 - y), 1))
1087
+ )
1088
+ guard gestureBounds.intersects(keyboardFrame) else {
1089
+ return original
1090
+ }
1091
+
1092
+ let window = app.windows.firstMatch
1093
+ let appFrame = window.exists && !window.frame.isEmpty ? window.frame : app.frame
1094
+ guard !appFrame.isEmpty else {
1095
+ return original
1096
+ }
1097
+
1098
+ let padding: Double = 12
1099
+ let targetMaxY = Double(keyboardFrame.minY) - padding
1100
+ let currentMaxY = max(y, y2)
1101
+ let shift = currentMaxY - targetMaxY
1102
+ guard shift > 0 else {
1103
+ return original
1104
+ }
1105
+
1106
+ let adjustedY = y - shift
1107
+ let adjustedY2 = y2 - shift
1108
+ guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
1109
+ return original
1110
+ }
1111
+
1112
+ NSLog(
1113
+ "AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
1114
+ x,
1115
+ y,
1116
+ x2,
1117
+ y2,
1118
+ x,
1119
+ adjustedY,
1120
+ x2,
1121
+ adjustedY2,
1122
+ Double(keyboardFrame.minY)
1123
+ )
1124
+ return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
1125
+ #else
1126
+ return original
1127
+ #endif
438
1128
  }
439
1129
 
440
1130
  func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
@@ -471,23 +1161,71 @@ extension RunnerTests {
471
1161
 
472
1162
  func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
473
1163
  let window = app.windows.firstMatch
474
- let windowFrame = window.frame
475
- if window.exists && !windowFrame.isEmpty {
476
- return windowFrame
1164
+ if window.exists {
1165
+ let windowFrame = window.frame
1166
+ if !windowFrame.isEmpty {
1167
+ return frameAvoidingKeyboard(app: app, frame: windowFrame)
1168
+ }
477
1169
  }
478
1170
  if !appFrame.isEmpty {
479
- return appFrame
1171
+ return frameAvoidingKeyboard(app: app, frame: appFrame)
480
1172
  }
481
1173
  return CGRect(x: 0, y: 0, width: 0, height: 0)
482
1174
  }
483
1175
 
1176
+ private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
1177
+ #if os(iOS)
1178
+ guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
1179
+ return frame
1180
+ }
1181
+ let intersection = frame.intersection(keyboardFrame)
1182
+ guard !intersection.isNull && intersection.height > 0 else {
1183
+ return frame
1184
+ }
1185
+ let keyboardCoverage = intersection.width / max(frame.width, 1)
1186
+ guard keyboardCoverage >= 0.5 else {
1187
+ return frame
1188
+ }
1189
+ let safeHeight = keyboardFrame.minY - frame.minY
1190
+ guard safeHeight >= frame.height * 0.25 else {
1191
+ return frame
1192
+ }
1193
+ return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
1194
+ #else
1195
+ return frame
1196
+ #endif
1197
+ }
1198
+
1199
+ private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
1200
+ #if os(iOS)
1201
+ var frame: CGRect?
1202
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1203
+ let keyboard = app.keyboards.firstMatch
1204
+ guard keyboard.exists else { return }
1205
+ let keyboardFrame = keyboard.frame
1206
+ guard !keyboardFrame.isEmpty else { return }
1207
+ frame = keyboardFrame
1208
+ })
1209
+ if let exceptionMessage {
1210
+ NSLog(
1211
+ "AGENT_DEVICE_RUNNER_KEYBOARD_FRAME_IGNORED_EXCEPTION=%@",
1212
+ exceptionMessage
1213
+ )
1214
+ return nil
1215
+ }
1216
+ return frame
1217
+ #else
1218
+ return nil
1219
+ #endif
1220
+ }
1221
+
484
1222
  func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
485
1223
  let total = max(count, 1)
486
1224
  let pause = max(pauseMs, 0)
487
1225
  for idx in 0..<total {
488
1226
  operation(idx)
489
1227
  if idx < total - 1 && pause > 0 {
490
- Thread.sleep(forTimeInterval: pause / 1000.0)
1228
+ sleepFor(pause / 1000.0)
491
1229
  }
492
1230
  }
493
1231
  }
@@ -510,26 +1248,28 @@ extension RunnerTests {
510
1248
  }
511
1249
 
512
1250
  private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
513
- #if os(tvOS)
514
1251
  switch direction {
515
1252
  case "up":
516
- XCUIRemote.shared.press(.up)
1253
+ return pressTvRemote(.up)
517
1254
  case "down":
518
- XCUIRemote.shared.press(.down)
1255
+ return pressTvRemote(.down)
519
1256
  case "left":
520
- XCUIRemote.shared.press(.left)
1257
+ return pressTvRemote(.left)
521
1258
  case "right":
522
- XCUIRemote.shared.press(.right)
1259
+ return pressTvRemote(.right)
523
1260
  default:
524
1261
  return false
525
1262
  }
526
- return true
527
- #else
528
- return false
529
- #endif
530
1263
  }
531
1264
 
532
- func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
1265
+ func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1266
+ return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
1267
+ }
1268
+
1269
+ private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1270
+ #if os(tvOS)
1271
+ return .unsupported("pinch is not supported on tvOS")
1272
+ #else
533
1273
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
534
1274
 
535
1275
  // Use double-tap + drag gesture for reliable map zoom
@@ -560,6 +1300,8 @@ extension RunnerTests {
560
1300
 
561
1301
  // Immediately press and drag (second tap + drag)
562
1302
  center.press(forDuration: 0.05, thenDragTo: endPoint)
1303
+ return .performed
1304
+ #endif
563
1305
  }
564
1306
 
565
1307
  private func interactionRoot(app: XCUIApplication) -> XCUIElement {
@@ -570,6 +1312,52 @@ extension RunnerTests {
570
1312
  return app
571
1313
  }
572
1314
 
1315
+ private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1316
+ #if os(tvOS)
1317
+ return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
1318
+ #else
1319
+ interactionCoordinate(app: app, x: x, y: y).tap()
1320
+ return .performed
1321
+ #endif
1322
+ }
1323
+
1324
+ private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1325
+ #if os(tvOS)
1326
+ return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
1327
+ #else
1328
+ interactionCoordinate(app: app, x: x, y: y).doubleTap()
1329
+ return .performed
1330
+ #endif
1331
+ }
1332
+
1333
+ private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
1334
+ #if os(tvOS)
1335
+ return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
1336
+ #else
1337
+ interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
1338
+ return .performed
1339
+ #endif
1340
+ }
1341
+
1342
+ private func performCoordinateDrag(
1343
+ app: XCUIApplication,
1344
+ x: Double,
1345
+ y: Double,
1346
+ x2: Double,
1347
+ y2: Double,
1348
+ holdDuration: TimeInterval
1349
+ ) -> RunnerInteractionOutcome {
1350
+ #if os(tvOS)
1351
+ return .unsupported("coordinate drag is not supported on tvOS")
1352
+ #else
1353
+ let start = interactionCoordinate(app: app, x: x, y: y)
1354
+ let end = interactionCoordinate(app: app, x: x2, y: y2)
1355
+ start.press(forDuration: holdDuration, thenDragTo: end)
1356
+ return .performed
1357
+ #endif
1358
+ }
1359
+
1360
+ #if !os(tvOS)
573
1361
  private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
574
1362
  let root = interactionRoot(app: app)
575
1363
  let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
@@ -578,14 +1366,17 @@ extension RunnerTests {
578
1366
  let offsetY = y - Double(rootFrame.origin.y)
579
1367
  return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
580
1368
  }
1369
+ #endif
581
1370
 
582
1371
  private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
583
1372
  let frame = element.frame
584
1373
  if !frame.isEmpty {
585
- tapAt(app: app, x: frame.midX, y: frame.midY)
1374
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
586
1375
  return
587
1376
  }
1377
+ #if !os(tvOS)
588
1378
  element.tap()
1379
+ #endif
589
1380
  }
590
1381
 
591
1382
  private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {