agent-device 0.14.7 → 0.14.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 (44) hide show
  1. package/README.md +119 -9
  2. package/android-snapshot-helper/README.md +4 -2
  3. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.7.apk → agent-device-android-snapshot-helper-0.14.9.apk} +0 -0
  4. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.9.apk.sha256 +1 -0
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.7.manifest.json → agent-device-android-snapshot-helper-0.14.9.manifest.json} +6 -6
  6. package/dist/src/180.js +1 -1
  7. package/dist/src/208.js +1 -0
  8. package/dist/src/221.js +3 -3
  9. package/dist/src/6108.js +26 -0
  10. package/dist/src/7462.js +1 -0
  11. package/dist/src/7719.js +1 -0
  12. package/dist/src/9542.js +2 -2
  13. package/dist/src/9639.js +2 -2
  14. package/dist/src/9671.js +1 -0
  15. package/dist/src/9818.js +1 -1
  16. package/dist/src/android-adb.d.ts +11 -2
  17. package/dist/src/android-snapshot-helper.d.ts +12 -2
  18. package/dist/src/cli.js +82 -0
  19. package/dist/src/command-schema.js +382 -0
  20. package/dist/src/contracts.d.ts +1 -0
  21. package/dist/src/finders.d.ts +1 -0
  22. package/dist/src/index.d.ts +6 -0
  23. package/dist/src/index.js +1 -1
  24. package/dist/src/internal/bin.js +2 -461
  25. package/dist/src/internal/daemon.js +20 -20
  26. package/dist/src/io.js +1 -1
  27. package/dist/src/remote-config.js +1 -1
  28. package/dist/src/selectors.d.ts +1 -0
  29. package/dist/src/server.js +20 -0
  30. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +86 -13
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +160 -93
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +1 -0
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +3 -0
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +15 -0
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +1 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +185 -0
  37. package/package.json +33 -6
  38. package/server.json +21 -0
  39. package/skills/agent-device/SKILL.md +11 -1
  40. package/skills/dogfood/SKILL.md +3 -1
  41. package/smithery.yaml +1 -0
  42. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.7.apk.sha256 +0 -1
  43. package/dist/src/2007.js +0 -26
  44. package/skills/react-devtools/SKILL.md +0 -48
@@ -26,6 +26,9 @@ extension RunnerTests {
26
26
  return true
27
27
  }
28
28
  return false
29
+ #elseif os(tvOS)
30
+ _ = pressTvRemote(.menu)
31
+ return true
29
32
  #else
30
33
  let buttons = app.navigationBars.buttons.allElementsBoundByIndex
31
34
  if let back = buttons.first(where: { $0.isHittable }) {
@@ -37,20 +40,26 @@ extension RunnerTests {
37
40
  }
38
41
 
39
42
  func performBackGesture(app: XCUIApplication) {
40
- if pressTvRemoteMenuIfAvailable() {
43
+ if pressTvRemote(.menu) {
41
44
  return
42
45
  }
46
+ performCoordinateBackGesture(app: app)
47
+ }
48
+
49
+ private func performCoordinateBackGesture(app: XCUIApplication) {
50
+ #if !os(tvOS)
43
51
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
44
52
  let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
45
53
  let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
46
54
  start.press(forDuration: 0.05, thenDragTo: end)
55
+ #endif
47
56
  }
48
57
 
49
58
  func performSystemBackAction(app: XCUIApplication) -> Bool {
50
59
  #if os(macOS)
51
60
  return false
52
61
  #else
53
- if pressTvRemoteMenuIfAvailable() {
62
+ if pressTvRemote(.menu) {
54
63
  return true
55
64
  }
56
65
  performBackGesture(app: app)
@@ -59,20 +68,28 @@ extension RunnerTests {
59
68
  }
60
69
 
61
70
  func performAppSwitcherGesture(app: XCUIApplication) {
62
- if performTvRemoteAppSwitcherIfAvailable() {
71
+ if pressTvRemote(.home) {
72
+ sleepFor(resolveTvRemoteDoublePressDelay())
73
+ _ = pressTvRemote(.home)
63
74
  return
64
75
  }
76
+ performCoordinateAppSwitcherGesture(app: app)
77
+ }
78
+
79
+ private func performCoordinateAppSwitcherGesture(app: XCUIApplication) {
80
+ #if !os(tvOS)
65
81
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
66
82
  let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
67
83
  let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
68
84
  start.press(forDuration: 0.6, thenDragTo: end)
85
+ #endif
69
86
  }
70
87
 
71
88
  func pressHomeButton() {
72
89
  #if os(macOS)
73
90
  return
74
91
  #else
75
- if pressTvRemoteHomeIfAvailable() {
92
+ if pressTvRemote(.home) {
76
93
  return
77
94
  }
78
95
  XCUIDevice.shared.press(.home)
@@ -80,7 +97,7 @@ extension RunnerTests {
80
97
  }
81
98
 
82
99
  func rotateDevice(to orientationName: String) -> Bool {
83
- #if os(macOS)
100
+ #if os(macOS) || os(tvOS)
84
101
  return false
85
102
  #else
86
103
  switch orientationName {
@@ -100,48 +117,6 @@ extension RunnerTests {
100
117
  #endif
101
118
  }
102
119
 
103
- private func pressTvRemoteMenuIfAvailable() -> Bool {
104
- #if os(tvOS)
105
- XCUIRemote.shared.press(.menu)
106
- return true
107
- #else
108
- return false
109
- #endif
110
- }
111
-
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
- }
120
-
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
130
- }
131
-
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
138
- }
139
- guard let parsedMs = Double(raw), parsedMs >= 0 else {
140
- return tvRemoteDoublePressDelayDefault
141
- }
142
- return min(parsedMs, 1000) / 1000.0
143
- }
144
-
145
120
  func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
146
121
  let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
147
122
  let element = app.descendants(matching: .any).matching(predicate).firstMatch
@@ -183,7 +158,9 @@ extension RunnerTests {
183
158
  }
184
159
 
185
160
  func clearTextInput(_ element: XCUIElement) {
161
+ #if !os(tvOS)
186
162
  moveCaretToEnd(element: element)
163
+ #endif
187
164
  let count = estimatedDeleteCount(for: element)
188
165
  let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
189
166
  element.typeText(deletes)
@@ -268,6 +245,12 @@ extension RunnerTests {
268
245
  return (wasVisible: false, dismissed: false, visible: false)
269
246
  }
270
247
 
248
+ #if os(tvOS)
249
+ _ = pressTvRemote(.menu)
250
+ sleepFor(0.2)
251
+ let visible = isKeyboardVisible(app: app)
252
+ return (wasVisible: true, dismissed: !visible, visible: visible)
253
+ #else
271
254
  let keyboard = app.keyboards.firstMatch
272
255
  keyboard.swipeDown()
273
256
  sleepFor(0.2)
@@ -282,9 +265,13 @@ extension RunnerTests {
282
265
  }
283
266
 
284
267
  return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
268
+ #endif
285
269
  }
286
270
 
287
271
  private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
272
+ #if os(tvOS)
273
+ return false
274
+ #else
288
275
  let keyboardFrame = app.keyboards.firstMatch.frame
289
276
  for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
290
277
  let candidates = [
@@ -313,6 +300,7 @@ extension RunnerTests {
313
300
  }
314
301
  }
315
302
  return false
303
+ #endif
316
304
  }
317
305
 
318
306
  private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
@@ -324,6 +312,9 @@ extension RunnerTests {
324
312
  }
325
313
 
326
314
  private func moveCaretToEnd(element: XCUIElement) {
315
+ #if os(tvOS)
316
+ return
317
+ #else
327
318
  let frame = element.frame
328
319
  guard !frame.isEmpty else {
329
320
  element.tap()
@@ -334,6 +325,7 @@ extension RunnerTests {
334
325
  CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
335
326
  )
336
327
  target.tap()
328
+ #endif
337
329
  }
338
330
 
339
331
  private func estimatedDeleteCount(for element: XCUIElement) -> Int {
@@ -379,49 +371,64 @@ extension RunnerTests {
379
371
  return element.exists ? element : nil
380
372
  }
381
373
 
382
- func tapAt(app: XCUIApplication, x: Double, y: Double) {
383
- let coordinate = interactionCoordinate(app: app, x: x, y: y)
384
- coordinate.tap()
374
+ func tapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
375
+ if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "tap") {
376
+ return outcome
377
+ }
378
+ return performCoordinateTap(app: app, x: x, y: y)
385
379
  }
386
380
 
387
381
  func mouseClickAt(app: XCUIApplication, x: Double, y: Double, button: String) throws {
382
+ #if os(macOS)
388
383
  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
384
+ switch button {
385
+ case "primary":
386
+ coordinate.tap()
387
+ case "secondary":
388
+ coordinate.rightClick()
389
+ case "middle":
390
+ throw NSError(
391
+ domain: "AgentDeviceRunner",
392
+ code: 1,
393
+ userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
394
+ )
395
+ default:
409
396
  throw NSError(
410
397
  domain: "AgentDeviceRunner",
411
398
  code: 1,
412
- userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
399
+ userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
413
400
  )
414
- #endif
401
+ }
402
+ #elseif os(tvOS)
403
+ throw NSError(
404
+ domain: "AgentDeviceRunner",
405
+ code: 1,
406
+ userInfo: [NSLocalizedDescriptionKey: "mouseClick is not supported on tvOS"]
407
+ )
408
+ #else
409
+ throw NSError(
410
+ domain: "AgentDeviceRunner",
411
+ code: 1,
412
+ userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
413
+ )
414
+ #endif
415
415
  }
416
416
 
417
- func doubleTapAt(app: XCUIApplication, x: Double, y: Double) {
418
- let coordinate = interactionCoordinate(app: app, x: x, y: y)
419
- coordinate.doubleTap()
417
+ func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
418
+ if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
419
+ guard case .performed = outcome else { return outcome }
420
+ sleepFor(0.1)
421
+ _ = pressTvRemote(.select)
422
+ return .performed
423
+ }
424
+ return performCoordinateDoubleTap(app: app, x: x, y: y)
420
425
  }
421
426
 
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)
427
+ func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
428
+ if let outcome = longSelectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), duration: duration) {
429
+ return outcome
430
+ }
431
+ return performCoordinateLongPress(app: app, x: x, y: y, duration: duration)
425
432
  }
426
433
 
427
434
  func dragAt(
@@ -431,10 +438,17 @@ extension RunnerTests {
431
438
  x2: Double,
432
439
  y2: Double,
433
440
  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)
441
+ ) -> RunnerInteractionOutcome {
442
+ // tvOS has no coordinate drag. Preserve the direction as a focus move.
443
+ let dx = x2 - x
444
+ let dy = y2 - y
445
+ let button: TvRemoteButton = abs(dx) > abs(dy)
446
+ ? (dx > 0 ? .right : .left)
447
+ : (dy > 0 ? .down : .up)
448
+ if pressTvRemote(button) {
449
+ return .performed
450
+ }
451
+ return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
438
452
  }
439
453
 
440
454
  func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
@@ -510,26 +524,28 @@ extension RunnerTests {
510
524
  }
511
525
 
512
526
  private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
513
- #if os(tvOS)
514
527
  switch direction {
515
528
  case "up":
516
- XCUIRemote.shared.press(.up)
529
+ return pressTvRemote(.up)
517
530
  case "down":
518
- XCUIRemote.shared.press(.down)
531
+ return pressTvRemote(.down)
519
532
  case "left":
520
- XCUIRemote.shared.press(.left)
533
+ return pressTvRemote(.left)
521
534
  case "right":
522
- XCUIRemote.shared.press(.right)
535
+ return pressTvRemote(.right)
523
536
  default:
524
537
  return false
525
538
  }
526
- return true
527
- #else
528
- return false
529
- #endif
530
539
  }
531
540
 
532
- func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
541
+ func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
542
+ return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
543
+ }
544
+
545
+ private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
546
+ #if os(tvOS)
547
+ return .unsupported("pinch is not supported on tvOS")
548
+ #else
533
549
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
534
550
 
535
551
  // Use double-tap + drag gesture for reliable map zoom
@@ -560,6 +576,8 @@ extension RunnerTests {
560
576
 
561
577
  // Immediately press and drag (second tap + drag)
562
578
  center.press(forDuration: 0.05, thenDragTo: endPoint)
579
+ return .performed
580
+ #endif
563
581
  }
564
582
 
565
583
  private func interactionRoot(app: XCUIApplication) -> XCUIElement {
@@ -570,6 +588,52 @@ extension RunnerTests {
570
588
  return app
571
589
  }
572
590
 
591
+ private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
592
+ #if os(tvOS)
593
+ return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
594
+ #else
595
+ interactionCoordinate(app: app, x: x, y: y).tap()
596
+ return .performed
597
+ #endif
598
+ }
599
+
600
+ private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
601
+ #if os(tvOS)
602
+ return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
603
+ #else
604
+ interactionCoordinate(app: app, x: x, y: y).doubleTap()
605
+ return .performed
606
+ #endif
607
+ }
608
+
609
+ private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
610
+ #if os(tvOS)
611
+ return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
612
+ #else
613
+ interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
614
+ return .performed
615
+ #endif
616
+ }
617
+
618
+ private func performCoordinateDrag(
619
+ app: XCUIApplication,
620
+ x: Double,
621
+ y: Double,
622
+ x2: Double,
623
+ y2: Double,
624
+ holdDuration: TimeInterval
625
+ ) -> RunnerInteractionOutcome {
626
+ #if os(tvOS)
627
+ return .unsupported("coordinate drag is not supported on tvOS")
628
+ #else
629
+ let start = interactionCoordinate(app: app, x: x, y: y)
630
+ let end = interactionCoordinate(app: app, x: x2, y: y2)
631
+ start.press(forDuration: holdDuration, thenDragTo: end)
632
+ return .performed
633
+ #endif
634
+ }
635
+
636
+ #if !os(tvOS)
573
637
  private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
574
638
  let root = interactionRoot(app: app)
575
639
  let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
@@ -578,14 +642,17 @@ extension RunnerTests {
578
642
  let offsetY = y - Double(rootFrame.origin.y)
579
643
  return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
580
644
  }
645
+ #endif
581
646
 
582
647
  private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
583
648
  let frame = element.frame
584
649
  if !frame.isEmpty {
585
- tapAt(app: app, x: frame.midX, y: frame.midY)
650
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
586
651
  return
587
652
  }
653
+ #if !os(tvOS)
588
654
  element.tap()
655
+ #endif
589
656
  }
590
657
 
591
658
  private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
@@ -184,6 +184,7 @@ extension RunnerTests {
184
184
  .tap,
185
185
  .longPress,
186
186
  .drag,
187
+ .remotePress,
187
188
  .type,
188
189
  .swipe,
189
190
  .back,
@@ -8,6 +8,7 @@ enum CommandType: String, Codable {
8
8
  case interactionFrame
9
9
  case drag
10
10
  case dragSeries
11
+ case remotePress
11
12
  case type
12
13
  case swipe
13
14
  case findText
@@ -39,6 +40,7 @@ struct Command: Codable {
39
40
  let x: Double?
40
41
  let y: Double?
41
42
  let button: String?
43
+ let remoteButton: String?
42
44
  let count: Double?
43
45
  let intervalMs: Double?
44
46
  let doubleTap: Bool?
@@ -162,6 +164,7 @@ struct SnapshotNode: Codable {
162
164
  let value: String?
163
165
  let rect: SnapshotRect
164
166
  let enabled: Bool
167
+ let focused: Bool?
165
168
  let hittable: Bool
166
169
  let depth: Int
167
170
  let parentIndex: Int?
@@ -28,6 +28,7 @@ extension RunnerTests {
28
28
  let identifier: String
29
29
  let valueText: String?
30
30
  let hittable: Bool
31
+ let focused: Bool
31
32
  let visible: Bool
32
33
  }
33
34
 
@@ -341,6 +342,7 @@ extension RunnerTests {
341
342
  identifier: identifier,
342
343
  valueText: valueText,
343
344
  hittable: computedSnapshotHittable(snapshot, viewport: context.viewport, laterNodes: laterNodes),
345
+ focused: snapshotHasFocus(snapshot),
344
346
  visible: isVisibleInViewport(snapshot.frame, context.viewport)
345
347
  )
346
348
  }
@@ -360,6 +362,7 @@ extension RunnerTests {
360
362
  value: evaluation.valueText,
361
363
  rect: snapshotRect(from: snapshot.frame),
362
364
  enabled: snapshot.isEnabled,
365
+ focused: evaluation.focused ? true : nil,
363
366
  hittable: evaluation.hittable,
364
367
  depth: depth,
365
368
  parentIndex: parentIndex,
@@ -525,6 +528,7 @@ extension RunnerTests {
525
528
  value: node.value,
526
529
  rect: node.rect,
527
530
  enabled: node.enabled,
531
+ focused: node.focused,
528
532
  hittable: node.hittable,
529
533
  depth: depth,
530
534
  parentIndex: parentIndex,
@@ -575,6 +579,7 @@ extension RunnerTests {
575
579
  value: valueText,
576
580
  rect: snapshotRect(from: frame),
577
581
  enabled: element.isEnabled,
582
+ focused: elementHasFocus(element) ? true : nil,
578
583
  hittable: element.isHittable,
579
584
  depth: 0,
580
585
  parentIndex: nil,
@@ -592,6 +597,16 @@ extension RunnerTests {
592
597
  return node
593
598
  }
594
599
 
600
+ private func snapshotHasFocus(_ snapshot: XCUIElementSnapshot) -> Bool {
601
+ var focused = false
602
+ _ = RunnerObjCExceptionCatcher.catchException({
603
+ if let value = (snapshot as! NSObject).value(forKey: "hasFocus") as? Bool {
604
+ focused = value
605
+ }
606
+ })
607
+ return focused
608
+ }
609
+
595
610
  private func shouldExpandCollapsedTabContainer(_ snapshot: XCUIElementSnapshot) -> Bool {
596
611
  let frame = snapshot.frame
597
612
  if frame.isNull || frame.isEmpty { return false }
@@ -186,6 +186,7 @@ extension RunnerTests {
186
186
  value: nil,
187
187
  rect: snapshotRect(from: element.frame),
188
188
  enabled: element.isEnabled,
189
+ focused: elementHasFocus(element) ? true : nil,
189
190
  hittable: hittableOverride ?? element.isHittable,
190
191
  depth: depth,
191
192
  parentIndex: nil,
@@ -0,0 +1,185 @@
1
+ import XCTest
2
+
3
+ enum RunnerInteractionOutcome {
4
+ case performed
5
+ case unsupported(String)
6
+ }
7
+
8
+ enum TvRemoteButton {
9
+ case select
10
+ case menu
11
+ case home
12
+ case up
13
+ case down
14
+ case left
15
+ case right
16
+ }
17
+
18
+ extension RunnerTests {
19
+ func resolveTvRemoteDoublePressDelay() -> TimeInterval {
20
+ guard
21
+ let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_TV_REMOTE_DOUBLE_PRESS_DELAY_MS"],
22
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
23
+ else {
24
+ return tvRemoteDoublePressDelayDefault
25
+ }
26
+ guard let parsedMs = Double(raw), parsedMs >= 0 else {
27
+ return tvRemoteDoublePressDelayDefault
28
+ }
29
+ return min(parsedMs, 1000) / 1000.0
30
+ }
31
+
32
+ @discardableResult
33
+ func pressTvRemote(_ button: TvRemoteButton, duration: TimeInterval? = nil) -> Bool {
34
+ #if os(tvOS)
35
+ let remoteButton = xcuiRemoteButton(button)
36
+ if let duration, duration > 0 {
37
+ XCUIRemote.shared.press(remoteButton, forDuration: duration)
38
+ } else {
39
+ XCUIRemote.shared.press(remoteButton)
40
+ }
41
+ return true
42
+ #else
43
+ return false
44
+ #endif
45
+ }
46
+
47
+ func tvRemoteButton(from raw: String?) -> TvRemoteButton? {
48
+ switch raw?.lowercased() {
49
+ case "select":
50
+ return .select
51
+ case "menu":
52
+ return .menu
53
+ case "home":
54
+ return .home
55
+ case "up":
56
+ return .up
57
+ case "down":
58
+ return .down
59
+ case "left":
60
+ return .left
61
+ case "right":
62
+ return .right
63
+ default:
64
+ return nil
65
+ }
66
+ }
67
+
68
+ func elementHasFocus(_ element: XCUIElement) -> Bool {
69
+ var focused = false
70
+ _ = RunnerObjCExceptionCatcher.catchException({
71
+ if let value = (element as NSObject).value(forKey: "hasFocus") as? Bool {
72
+ focused = value
73
+ }
74
+ })
75
+ return focused
76
+ }
77
+
78
+ func activateElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome {
79
+ if let outcome = selectFocusedTvElement(app: app, element: element, action: action) {
80
+ return outcome
81
+ }
82
+ return performElementTap(element)
83
+ }
84
+
85
+ func selectFocusedTvElement(app: XCUIApplication, point: CGPoint, action: String) -> RunnerInteractionOutcome? {
86
+ #if os(tvOS)
87
+ guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
88
+ return .unsupported("\(action) is supported on tvOS only when the requested point is inside the focused element")
89
+ }
90
+ _ = pressTvRemote(.select)
91
+ return .performed
92
+ #else
93
+ return nil
94
+ #endif
95
+ }
96
+
97
+ func longSelectFocusedTvElement(app: XCUIApplication, point: CGPoint, duration: TimeInterval) -> RunnerInteractionOutcome? {
98
+ #if os(tvOS)
99
+ guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
100
+ return .unsupported("long press is supported on tvOS only when the requested point is inside the focused element")
101
+ }
102
+ _ = pressTvRemote(.select, duration: duration)
103
+ return .performed
104
+ #else
105
+ return nil
106
+ #endif
107
+ }
108
+
109
+ private func performElementTap(_ element: XCUIElement) -> RunnerInteractionOutcome {
110
+ #if os(tvOS)
111
+ return .unsupported("element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
112
+ #else
113
+ element.tap()
114
+ return .performed
115
+ #endif
116
+ }
117
+
118
+ private func selectFocusedTvElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome? {
119
+ #if os(tvOS)
120
+ guard tvFocusedElementMatches(app: app, target: element) else {
121
+ return .unsupported("\(action) is supported on tvOS only when the requested element is focused")
122
+ }
123
+ _ = pressTvRemote(.select)
124
+ return .performed
125
+ #else
126
+ return nil
127
+ #endif
128
+ }
129
+
130
+ private func tvFocusedElementMatches(app: XCUIApplication, target: XCUIElement) -> Bool {
131
+ #if os(tvOS)
132
+ if target.hasFocus {
133
+ return true
134
+ }
135
+ guard let focused = focusedTvElement(app: app) else {
136
+ return false
137
+ }
138
+ let targetFrame = target.frame
139
+ let focusedFrame = focused.frame
140
+ guard !targetFrame.isEmpty && !focusedFrame.isEmpty else {
141
+ return false
142
+ }
143
+ let focusedCenter = CGPoint(x: focusedFrame.midX, y: focusedFrame.midY)
144
+ let targetCenter = CGPoint(x: targetFrame.midX, y: targetFrame.midY)
145
+ return targetFrame.contains(focusedCenter)
146
+ || focusedFrame.contains(targetCenter)
147
+ || targetFrame.intersects(focusedFrame)
148
+ #else
149
+ return false
150
+ #endif
151
+ }
152
+
153
+ private func focusedTvElement(app: XCUIApplication) -> XCUIElement? {
154
+ #if os(tvOS)
155
+ let focused = app
156
+ .descendants(matching: .any)
157
+ .matching(NSPredicate(format: "hasFocus == true"))
158
+ .firstMatch
159
+ return focused.exists ? focused : nil
160
+ #else
161
+ return nil
162
+ #endif
163
+ }
164
+
165
+ #if os(tvOS)
166
+ private func xcuiRemoteButton(_ button: TvRemoteButton) -> XCUIRemote.Button {
167
+ switch button {
168
+ case .select:
169
+ return .select
170
+ case .menu:
171
+ return .menu
172
+ case .home:
173
+ return .home
174
+ case .up:
175
+ return .up
176
+ case .down:
177
+ return .down
178
+ case .left:
179
+ return .left
180
+ case .right:
181
+ return .right
182
+ }
183
+ }
184
+ #endif
185
+ }