agent-device 0.14.8 → 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 (32) hide show
  1. package/README.md +1 -2
  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.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.8.manifest.json → agent-device-android-snapshot-helper-0.14.9.manifest.json} +6 -6
  6. package/dist/src/6108.js +17 -17
  7. package/dist/src/7462.js +1 -1
  8. package/dist/src/9542.js +1 -1
  9. package/dist/src/9639.js +2 -2
  10. package/dist/src/9818.js +1 -1
  11. package/dist/src/android-adb.d.ts +11 -2
  12. package/dist/src/android-snapshot-helper.d.ts +12 -2
  13. package/dist/src/cli.js +46 -46
  14. package/dist/src/command-schema.js +1 -0
  15. package/dist/src/contracts.d.ts +1 -0
  16. package/dist/src/finders.d.ts +1 -0
  17. package/dist/src/index.d.ts +6 -0
  18. package/dist/src/internal/daemon.js +20 -20
  19. package/dist/src/selectors.d.ts +1 -0
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +86 -13
  21. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +160 -93
  22. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +1 -0
  23. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +3 -0
  24. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +15 -0
  25. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +1 -0
  26. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +185 -0
  27. package/package.json +1 -1
  28. package/server.json +3 -3
  29. package/skills/agent-device/SKILL.md +11 -1
  30. package/skills/dogfood/SKILL.md +3 -1
  31. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.8.apk.sha256 +0 -1
  32. package/skills/react-devtools/SKILL.md +0 -48
@@ -35,6 +35,7 @@ declare type RawSnapshotNode = {
35
35
  rect?: Rect;
36
36
  enabled?: boolean;
37
37
  selected?: boolean;
38
+ focused?: boolean;
38
39
  hittable?: boolean;
39
40
  depth?: number;
40
41
  parentIndex?: number;
@@ -13,6 +13,18 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
+ private func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
17
+ switch outcome {
18
+ case .performed:
19
+ return nil
20
+ case .unsupported(let message):
21
+ return Response(
22
+ ok: false,
23
+ error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message)
24
+ )
25
+ }
26
+ }
27
+
16
28
  func execute(command: Command) throws -> Response {
17
29
  if Thread.isMainThread {
18
30
  return try executeOnMainSafely(command: command)
@@ -231,11 +243,15 @@ extension RunnerTests {
231
243
  case .tap:
232
244
  if let text = command.text {
233
245
  if let element = findElement(app: activeApp, text: text) {
246
+ var outcome = RunnerInteractionOutcome.performed
234
247
  let timing = measureGesture {
235
248
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
236
- element.tap()
249
+ outcome = activateElement(app: activeApp, element: element, action: "tap by text")
237
250
  }
238
251
  }
252
+ if let response = unsupportedResponse(for: outcome) {
253
+ return response
254
+ }
239
255
  return Response(
240
256
  ok: true,
241
257
  data: DataPayload(
@@ -249,11 +265,15 @@ extension RunnerTests {
249
265
  }
250
266
  if let x = command.x, let y = command.y {
251
267
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
268
+ var outcome = RunnerInteractionOutcome.performed
252
269
  let timing = measureGesture {
253
270
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
254
- tapAt(app: activeApp, x: x, y: y)
271
+ outcome = tapAt(app: activeApp, x: x, y: y)
255
272
  }
256
273
  }
274
+ if let response = unsupportedResponse(for: outcome) {
275
+ return response
276
+ }
257
277
  return Response(
258
278
  ok: true,
259
279
  data: DataPayload(
@@ -309,13 +329,19 @@ extension RunnerTests {
309
329
  let doubleTap = command.doubleTap ?? false
310
330
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
311
331
  if doubleTap {
332
+ var outcome = RunnerInteractionOutcome.performed
312
333
  let timing = measureGesture {
313
334
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
314
335
  runSeries(count: count, pauseMs: intervalMs) { _ in
315
- doubleTapAt(app: activeApp, x: x, y: y)
336
+ if case .performed = outcome {
337
+ outcome = doubleTapAt(app: activeApp, x: x, y: y)
338
+ }
316
339
  }
317
340
  }
318
341
  }
342
+ if let response = unsupportedResponse(for: outcome) {
343
+ return response
344
+ }
319
345
  return Response(
320
346
  ok: true,
321
347
  data: DataPayload(
@@ -329,13 +355,19 @@ extension RunnerTests {
329
355
  )
330
356
  )
331
357
  }
358
+ var outcome = RunnerInteractionOutcome.performed
332
359
  let timing = measureGesture {
333
360
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
334
361
  runSeries(count: count, pauseMs: intervalMs) { _ in
335
- tapAt(app: activeApp, x: x, y: y)
362
+ if case .performed = outcome {
363
+ outcome = tapAt(app: activeApp, x: x, y: y)
364
+ }
336
365
  }
337
366
  }
338
367
  }
368
+ if let response = unsupportedResponse(for: outcome) {
369
+ return response
370
+ }
339
371
  return Response(
340
372
  ok: true,
341
373
  data: DataPayload(
@@ -354,11 +386,15 @@ extension RunnerTests {
354
386
  }
355
387
  let duration = (command.durationMs ?? 800) / 1000.0
356
388
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
389
+ var outcome = RunnerInteractionOutcome.performed
357
390
  let timing = measureGesture {
358
391
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
359
- longPressAt(app: activeApp, x: x, y: y, duration: duration)
392
+ outcome = longPressAt(app: activeApp, x: x, y: y, duration: duration)
360
393
  }
361
394
  }
395
+ if let response = unsupportedResponse(for: outcome) {
396
+ return response
397
+ }
362
398
  return Response(
363
399
  ok: true,
364
400
  data: DataPayload(
@@ -377,11 +413,15 @@ extension RunnerTests {
377
413
  }
378
414
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
379
415
  let dragFrame = resolvedDragVisualizationFrame(app: activeApp, x: x, y: y, x2: x2, y2: y2)
416
+ var outcome = RunnerInteractionOutcome.performed
380
417
  let timing = measureGesture {
381
418
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
382
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
419
+ outcome = dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
383
420
  }
384
421
  }
422
+ if let response = unsupportedResponse(for: outcome) {
423
+ return response
424
+ }
385
425
  return Response(
386
426
  ok: true,
387
427
  data: DataPayload(
@@ -407,18 +447,25 @@ extension RunnerTests {
407
447
  return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
408
448
  }
409
449
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
450
+ var outcome = RunnerInteractionOutcome.performed
410
451
  let timing = measureGesture {
411
452
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
412
453
  runSeries(count: count, pauseMs: pauseMs) { idx in
454
+ guard case .performed = outcome else {
455
+ return
456
+ }
413
457
  let reverse = pattern == "ping-pong" && (idx % 2 == 1)
414
458
  if reverse {
415
- dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
459
+ outcome = dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
416
460
  } else {
417
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
461
+ outcome = dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
418
462
  }
419
463
  }
420
464
  }
421
465
  }
466
+ if let response = unsupportedResponse(for: outcome) {
467
+ return response
468
+ }
422
469
  return Response(
423
470
  ok: true,
424
471
  data: DataPayload(
@@ -427,6 +474,18 @@ extension RunnerTests {
427
474
  gestureEndUptimeMs: timing.gestureEndUptimeMs
428
475
  )
429
476
  )
477
+ case .remotePress:
478
+ guard let button = tvRemoteButton(from: command.remoteButton) else {
479
+ return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
480
+ }
481
+ let duration = (command.durationMs ?? 0) / 1000.0
482
+ guard pressTvRemote(button, duration: duration) else {
483
+ return Response(
484
+ ok: false,
485
+ error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: "remotePress is only supported on tvOS")
486
+ )
487
+ }
488
+ return Response(ok: true, data: DataPayload(message: "remote pressed"))
430
489
  case .type:
431
490
  guard let text = command.text else {
432
491
  return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -633,13 +692,23 @@ extension RunnerTests {
633
692
  return Response(ok: false, error: ErrorPayload(message: "alert not found"))
634
693
  }
635
694
  if action == "accept" {
636
- let button = alert.buttons.allElementsBoundByIndex.first
637
- button?.tap()
695
+ guard let button = alert.buttons.allElementsBoundByIndex.first else {
696
+ return Response(ok: false, error: ErrorPayload(message: "alert accept button not found"))
697
+ }
698
+ let outcome = activateElement(app: activeApp, element: button, action: "alert accept")
699
+ if let response = unsupportedResponse(for: outcome) {
700
+ return response
701
+ }
638
702
  return Response(ok: true, data: DataPayload(message: "accepted"))
639
703
  }
640
704
  if action == "dismiss" {
641
- let button = alert.buttons.allElementsBoundByIndex.last
642
- button?.tap()
705
+ guard let button = alert.buttons.allElementsBoundByIndex.last else {
706
+ return Response(ok: false, error: ErrorPayload(message: "alert dismiss button not found"))
707
+ }
708
+ let outcome = activateElement(app: activeApp, element: button, action: "alert dismiss")
709
+ if let response = unsupportedResponse(for: outcome) {
710
+ return response
711
+ }
643
712
  return Response(ok: true, data: DataPayload(message: "dismissed"))
644
713
  }
645
714
  let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
@@ -648,8 +717,12 @@ extension RunnerTests {
648
717
  guard let scale = command.scale, scale > 0 else {
649
718
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
650
719
  }
720
+ var outcome = RunnerInteractionOutcome.performed
651
721
  let timing = measureGesture {
652
- pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
722
+ outcome = pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
723
+ }
724
+ if let response = unsupportedResponse(for: outcome) {
725
+ return response
653
726
  }
654
727
  return Response(
655
728
  ok: true,
@@ -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?