agent-device 0.20.1 → 0.20.3

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 (108) hide show
  1. package/README.md +0 -2
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.1.apk → agent-device-android-ime-helper-0.20.3.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.3.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.1.manifest.json → agent-device-android-ime-helper-0.20.3.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.1.apk → agent-device-android-snapshot-helper-0.20.3.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.3.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.1.manifest.json → agent-device-android-snapshot-helper-0.20.3.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +2 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +1 -35
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -208
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +184 -0
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Orientation.swift +92 -0
  13. package/dist/src/agent-device-client.js +2 -2
  14. package/dist/src/android-adb.d.ts +3 -26
  15. package/dist/src/android-adb.js +1 -1
  16. package/dist/src/android.js +1 -1
  17. package/dist/src/app-helpers.js +1 -0
  18. package/dist/src/app-lifecycle.js +5 -5
  19. package/dist/src/app-log-request-scope.js +1 -1
  20. package/dist/src/app-resolution.js +1 -1
  21. package/dist/src/apps.js +5 -5
  22. package/dist/src/cli-config.js +1 -1
  23. package/dist/src/cli-help.js +6 -6
  24. package/dist/src/cli-runner.js +1 -1
  25. package/dist/src/cli.js +14 -14
  26. package/dist/src/click-button.d.ts +5 -0
  27. package/dist/src/client-metro.js +1 -0
  28. package/dist/src/command-schema.js +3 -3
  29. package/dist/src/context.js +1 -1
  30. package/dist/src/contracts.d.ts +1 -1
  31. package/dist/src/device-input-state.d.ts +26 -0
  32. package/dist/src/device-input-state.js +1 -1
  33. package/dist/src/device.js +9 -0
  34. package/dist/src/devices2.js +1 -1
  35. package/dist/src/find.js +1 -1
  36. package/dist/src/generic.js +1 -1
  37. package/dist/src/gesture-plan-types.js +1 -0
  38. package/dist/src/gesture-plan.js +1 -0
  39. package/dist/src/index.d.ts +1174 -1413
  40. package/dist/src/input-actions.js +4 -4
  41. package/dist/src/install-artifact.js +5 -1
  42. package/dist/src/install-artifact2.js +1 -0
  43. package/dist/src/interaction-snapshot.js +1 -1
  44. package/dist/src/interaction.d.ts +3 -6
  45. package/dist/src/interaction.js +1 -1
  46. package/dist/src/interactor.js +2 -2
  47. package/dist/src/interactor2.js +1 -1
  48. package/dist/src/internal/bin.js +1 -1
  49. package/dist/src/internal/daemon.js +14 -22
  50. package/dist/src/lease-context.js +1 -1
  51. package/dist/src/lease.js +1 -1
  52. package/dist/src/limrun.d.ts +437 -0
  53. package/dist/src/limrun.js +1 -0
  54. package/dist/src/linux.js +1 -1
  55. package/dist/src/logcat.js +1 -5
  56. package/dist/src/metro.d.ts +71 -2
  57. package/dist/src/metro.js +1 -1
  58. package/dist/src/metro2.js +1 -1
  59. package/dist/src/notifications.js +1 -1
  60. package/dist/src/physical-device-control.js +1 -0
  61. package/dist/src/plist.js +1 -0
  62. package/dist/src/react-native.js +1 -1
  63. package/dist/src/recording-export-quality.js +12 -13
  64. package/dist/src/register-builtins.js +1 -1
  65. package/dist/src/registry.js +18 -18
  66. package/dist/src/remote-config.d.ts +5 -2
  67. package/dist/src/remote-config.js +1 -1
  68. package/dist/src/remote-config2.js +1 -0
  69. package/dist/src/reporting.js +8 -8
  70. package/dist/src/runner-client.js +1 -1
  71. package/dist/src/runner-disposal.js +1 -2
  72. package/dist/src/runner-lease.js +1 -1
  73. package/dist/src/runtime-transport.js +1 -1
  74. package/dist/src/runtime.js +3 -3
  75. package/dist/src/runtime2.js +1 -1
  76. package/dist/src/sdk-android-adb.d.ts +4 -2
  77. package/dist/src/sdk-batch-runner.d.ts +13 -3
  78. package/dist/src/sdk-batch-runner.js +1 -1
  79. package/dist/src/sdk-contracts.d.ts +28 -2
  80. package/dist/src/sdk-contracts.js +2 -2
  81. package/dist/src/sdk-finders.d.ts +1 -1
  82. package/dist/src/sdk-remote-config.d.ts +119 -4
  83. package/dist/src/selector-runtime.js +1 -1
  84. package/dist/src/selector-vocabulary.js +1 -1
  85. package/dist/src/selectors.d.ts +1 -1
  86. package/dist/src/server.js +2 -2
  87. package/dist/src/session-target-evidence.js +1 -1
  88. package/dist/src/session.js +8 -8
  89. package/dist/src/simulator.js +1 -1
  90. package/dist/src/snapshot-diagnostics.js +4 -4
  91. package/dist/src/snapshot2.js +1 -1
  92. package/dist/src/tool-provider.js +3 -3
  93. package/dist/src/tool-provider2.js +1 -1
  94. package/dist/src/tool-provider3.js +1 -1
  95. package/dist/src/tv-remote.d.ts +213 -0
  96. package/dist/src/xml.js +2 -0
  97. package/package.json +49 -30
  98. package/server.json +2 -2
  99. package/smithery.yaml +1 -1
  100. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.1.apk.sha256 +0 -1
  101. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.1.apk.sha256 +0 -1
  102. package/dist/src/android-system-chrome.js +0 -1
  103. package/dist/src/client-metro-companion.js +0 -1
  104. package/dist/src/metro-types.d.ts +0 -52
  105. package/dist/src/project-runtime.js +0 -1
  106. package/dist/src/scoped-provider.js +0 -1
  107. package/dist/src/simctl.js +0 -1
  108. package/dist/src/snapshot-quality.js +0 -1
package/README.md CHANGED
@@ -28,9 +28,7 @@ Install the CLI and check setup. It requires Node.js 22.12 or newer; web automat
28
28
  ```bash
29
29
  npm install -g agent-device@latest
30
30
  agent-device doctor
31
- agent-device --version
32
31
  agent-device help workflow
33
- agent-device help tv
34
32
  ```
35
33
 
36
34
  Run `agent-device doctor` yourself before handing the CLI to an agent. The installed CLI help defines current behavior. `agent-device help workflow` links to guides for debugging, replay, React Native profiling, and other tasks.
@@ -0,0 +1 @@
1
+ 02777b8c7e55044210eafa255ac92e627977679d75ba4a97f563f3e23dea3267 agent-device-android-ime-helper-0.20.3.apk
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "android-ime-helper",
3
- "version": "0.20.1",
4
- "assetName": "agent-device-android-ime-helper-0.20.1.apk",
5
- "sha256": "8dc8135211465ef283d1c5175024638becb783b6975ecfd0b9a3ea38637eaa1c",
3
+ "version": "0.20.3",
4
+ "assetName": "agent-device-android-ime-helper-0.20.3.apk",
5
+ "sha256": "02777b8c7e55044210eafa255ac92e627977679d75ba4a97f563f3e23dea3267",
6
6
  "packageName": "com.callstack.agentdevice.imehelper",
7
- "versionCode": 20001,
7
+ "versionCode": 20003,
8
8
  "serviceComponent": "com.callstack.agentdevice.imehelper/.TestInputMethodService",
9
9
  "broadcastProtocol": "android-ime-helper-v1"
10
10
  }
@@ -0,0 +1 @@
1
+ de7ecffc80d54dd1c173d3bf445ff8c9a02de2d124bcf39abeafeb5a6a08c95e agent-device-android-snapshot-helper-0.20.3.apk
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "android-snapshot-helper",
3
- "version": "0.20.1",
4
- "releaseTag": "v0.20.1",
5
- "assetName": "agent-device-android-snapshot-helper-0.20.1.apk",
3
+ "version": "0.20.3",
4
+ "releaseTag": "v0.20.3",
5
+ "assetName": "agent-device-android-snapshot-helper-0.20.3.apk",
6
6
  "apkUrl": null,
7
- "sha256": "26ae795374725ac91a513dc2943521fdc16bf67c25e91cb1f49d0fa62f61c85e",
8
- "checksumName": "agent-device-android-snapshot-helper-0.20.1.apk.sha256",
7
+ "sha256": "de7ecffc80d54dd1c173d3bf445ff8c9a02de2d124bcf39abeafeb5a6a08c95e",
8
+ "checksumName": "agent-device-android-snapshot-helper-0.20.3.apk.sha256",
9
9
  "packageName": "com.callstack.agentdevice.snapshothelper",
10
- "versionCode": 20001,
10
+ "versionCode": 20003,
11
11
  "instrumentationRunner": "com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation",
12
12
  "minSdk": 23,
13
13
  "targetSdk": 36,
@@ -220,10 +220,12 @@ extension RunnerTests {
220
220
  "yes",
221
221
  "continue",
222
222
  "done",
223
+ "open",
223
224
  "open settings"
224
225
  ].contains(normalized) || normalized.hasPrefix("confirm")
225
226
  }
226
227
 
228
+
227
229
  private func isDismissButton(_ label: String) -> Bool {
228
230
  [
229
231
  "cancel",
@@ -1287,41 +1287,7 @@ extension RunnerTests {
1287
1287
  pressHomeButton()
1288
1288
  return Response(ok: true, data: DataPayload(message: "home"))
1289
1289
  case .rotate:
1290
- guard let orientation = command.orientation?.trimmingCharacters(in: .whitespacesAndNewlines),
1291
- !orientation.isEmpty
1292
- else {
1293
- return Response(ok: false, error: ErrorPayload(message: "rotate requires orientation"))
1294
- }
1295
- let supportedOrientations = [
1296
- "portrait",
1297
- "portrait-upside-down",
1298
- "landscape-left",
1299
- "landscape-right",
1300
- ]
1301
- guard supportedOrientations.contains(orientation) else {
1302
- return Response(
1303
- ok: false,
1304
- error: ErrorPayload(message: "unsupported rotate orientation: \(orientation)")
1305
- )
1306
- }
1307
- guard let observedOrientation = rotateDevice(to: orientation) else {
1308
- return Response(
1309
- ok: false,
1310
- error: ErrorPayload(message: "unable to observe device orientation after rotate")
1311
- )
1312
- }
1313
- guard observedOrientation == orientation else {
1314
- return Response(
1315
- ok: false,
1316
- error: ErrorPayload(
1317
- message: "device orientation is \(observedOrientation), expected \(orientation)"
1318
- )
1319
- )
1320
- }
1321
- return Response(
1322
- ok: true,
1323
- data: DataPayload(message: "rotate", orientation: observedOrientation)
1324
- )
1290
+ return executeRotateCommand(command)
1325
1291
  case .appSwitcher:
1326
1292
  performAppSwitcherGesture(app: activeApp)
1327
1293
  return Response(ok: true, data: DataPayload(message: "appSwitcher"))
@@ -118,38 +118,6 @@ extension RunnerTests {
118
118
  #endif
119
119
  }
120
120
 
121
- func rotateDevice(to orientationName: String) -> String? {
122
- #if os(macOS) || os(tvOS) || os(visionOS)
123
- return nil
124
- #else
125
- switch orientationName {
126
- case "portrait":
127
- XCUIDevice.shared.orientation = .portrait
128
- case "portrait-upside-down":
129
- XCUIDevice.shared.orientation = .portraitUpsideDown
130
- case "landscape-left":
131
- XCUIDevice.shared.orientation = .landscapeLeft
132
- case "landscape-right":
133
- XCUIDevice.shared.orientation = .landscapeRight
134
- default:
135
- return nil
136
- }
137
- sleepFor(0.2)
138
- switch XCUIDevice.shared.orientation {
139
- case .portrait:
140
- return "portrait"
141
- case .portraitUpsideDown:
142
- return "portrait-upside-down"
143
- case .landscapeLeft:
144
- return "landscape-left"
145
- case .landscapeRight:
146
- return "landscape-right"
147
- default:
148
- return nil
149
- }
150
- #endif
151
- }
152
-
153
121
  func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
154
122
  let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
155
123
  let element = app.descendants(matching: .any).matching(predicate).firstMatch
@@ -367,168 +335,6 @@ extension RunnerTests {
367
335
  && point.y <= frame.maxY + tolerance
368
336
  }
369
337
 
370
- func isKeyboardVisible(app: XCUIApplication) -> Bool {
371
- return visibleKeyboardFrame(app: app) != nil
372
- }
373
-
374
- func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
375
- let wasVisible = isKeyboardVisible(app: app)
376
- guard wasVisible else {
377
- return (wasVisible: false, dismissed: false, visible: false)
378
- }
379
-
380
- #if os(tvOS)
381
- _ = pressTvRemote(.menu)
382
- sleepFor(0.2)
383
- let visible = isKeyboardVisible(app: app)
384
- return (wasVisible: true, dismissed: !visible, visible: visible)
385
- #else
386
- if tapKeyboardDismissControl(app: app) {
387
- sleepFor(0.2)
388
- let visible = isKeyboardVisible(app: app)
389
- return (wasVisible: true, dismissed: !visible, visible: visible)
390
- }
391
-
392
- return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
393
- #endif
394
- }
395
-
396
- func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
397
- #if os(tvOS)
398
- return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
399
- #elseif os(iOS)
400
- let wasVisible = isKeyboardVisible(app: app)
401
- if tapKeyboardReturnControl(app: app) {
402
- sleepFor(0.2)
403
- return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
404
- }
405
-
406
- var typed = false
407
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
408
- app.typeText(XCUIKeyboardKey.return.rawValue)
409
- typed = true
410
- })
411
- if let exceptionMessage {
412
- NSLog(
413
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
414
- exceptionMessage
415
- )
416
- if let singleTarget = singleTextEntryElement(app: app) {
417
- return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
418
- }
419
- return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
420
- }
421
- sleepFor(0.2)
422
- return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
423
- #else
424
- return (wasVisible: false, pressed: false, visible: false)
425
- #endif
426
- }
427
-
428
- private func pressKeyboardReturn(
429
- on element: XCUIElement,
430
- app: XCUIApplication,
431
- wasVisible: Bool
432
- ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
433
- #if os(iOS)
434
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
435
- element.tap()
436
- element.typeText(XCUIKeyboardKey.return.rawValue)
437
- })
438
- if let exceptionMessage {
439
- NSLog(
440
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
441
- exceptionMessage
442
- )
443
- return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
444
- }
445
- sleepFor(0.2)
446
- return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
447
- #else
448
- return (wasVisible: wasVisible, pressed: false, visible: false)
449
- #endif
450
- }
451
-
452
- private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
453
- #if os(iOS)
454
- let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
455
- app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
456
- guard element.exists else { return false }
457
- switch element.elementType {
458
- case .textField, .secureTextField, .searchField, .textView:
459
- return true
460
- default:
461
- return false
462
- }
463
- }
464
- }
465
- return matches.count == 1 ? matches[0] : nil
466
- #else
467
- return nil
468
- #endif
469
- }
470
-
471
- private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
472
- #if os(tvOS)
473
- return false
474
- #else
475
- guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
476
- return false
477
- }
478
- for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
479
- let candidates = [
480
- app.keyboards.buttons[label],
481
- app.keyboards.keys[label],
482
- app.keyboards.toolbars.buttons[label],
483
- ]
484
- if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
485
- hittable.tap()
486
- return true
487
- }
488
-
489
- let toolbarButtonPredicate = NSPredicate(
490
- format: "label == %@ OR identifier == %@",
491
- label,
492
- label
493
- )
494
- let toolbarButtons = app.descendants(matching: .button)
495
- .matching(toolbarButtonPredicate)
496
- .allElementsBoundByIndex
497
- if let hittable = toolbarButtons.first(where: {
498
- $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
499
- }) {
500
- hittable.tap()
501
- return true
502
- }
503
- }
504
- return false
505
- #endif
506
- }
507
-
508
- private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
509
- #if os(iOS)
510
- for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
511
- let candidates = [
512
- app.keyboards.buttons[label],
513
- app.keyboards.keys[label],
514
- ]
515
- if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
516
- hittable.tap()
517
- return true
518
- }
519
- }
520
- #endif
521
- return false
522
- }
523
-
524
- private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
525
- let frame = element.frame
526
- guard !frame.isEmpty && !keyboardFrame.isEmpty else {
527
- return false
528
- }
529
- return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
530
- }
531
-
532
338
  private func readableText(for element: XCUIElement) -> String? {
533
339
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
534
340
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1037,20 +843,6 @@ extension RunnerTests {
1037
843
  #endif
1038
844
  }
1039
845
 
1040
- private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
1041
- #if os(iOS)
1042
- return safely("KEYBOARD_FRAME") {
1043
- let keyboard = app.keyboards.firstMatch
1044
- guard keyboard.exists else { return nil }
1045
- let keyboardFrame = keyboard.frame
1046
- guard !keyboardFrame.isEmpty else { return nil }
1047
- return keyboardFrame
1048
- }
1049
- #else
1050
- return nil
1051
- #endif
1052
- }
1053
-
1054
846
  func axFreeSynthesizedDragPlan(
1055
847
  app: XCUIApplication,
1056
848
  x: Double,
@@ -0,0 +1,184 @@
1
+ import XCTest
2
+
3
+ private enum KeyboardDismissObservationTiming {
4
+ static let timeout: TimeInterval = 2
5
+ }
6
+
7
+ extension RunnerTests {
8
+ func isKeyboardVisible(app: XCUIApplication) -> Bool {
9
+ return visibleKeyboardFrame(app: app) != nil
10
+ }
11
+
12
+ func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
13
+ let keyboard = app.keyboards.firstMatch
14
+ let wasVisible = isKeyboardVisible(app: app)
15
+ guard wasVisible else {
16
+ return (wasVisible: false, dismissed: false, visible: false)
17
+ }
18
+
19
+ #if os(tvOS)
20
+ _ = pressTvRemote(.menu)
21
+ sleepFor(0.2)
22
+ let visible = isKeyboardVisible(app: app)
23
+ return (wasVisible: true, dismissed: !visible, visible: visible)
24
+ #else
25
+ if tapKeyboardDismissControl(app: app) {
26
+ _ = keyboard.waitForNonExistence(timeout: KeyboardDismissObservationTiming.timeout)
27
+ let visible = isKeyboardVisible(app: app)
28
+ return (wasVisible: true, dismissed: !visible, visible: visible)
29
+ }
30
+
31
+ return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
32
+ #endif
33
+ }
34
+
35
+ func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
36
+ #if os(tvOS)
37
+ return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
38
+ #elseif os(iOS)
39
+ let wasVisible = isKeyboardVisible(app: app)
40
+ if tapKeyboardReturnControl(app: app) {
41
+ sleepFor(0.2)
42
+ return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
43
+ }
44
+
45
+ var typed = false
46
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
47
+ app.typeText(XCUIKeyboardKey.return.rawValue)
48
+ typed = true
49
+ })
50
+ if let exceptionMessage {
51
+ NSLog(
52
+ "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
53
+ exceptionMessage
54
+ )
55
+ if let singleTarget = singleTextEntryElement(app: app) {
56
+ return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
57
+ }
58
+ return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
59
+ }
60
+ sleepFor(0.2)
61
+ return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
62
+ #else
63
+ return (wasVisible: false, pressed: false, visible: false)
64
+ #endif
65
+ }
66
+
67
+ func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
68
+ #if os(iOS)
69
+ return safely("KEYBOARD_FRAME") {
70
+ let keyboard = app.keyboards.firstMatch
71
+ guard keyboard.exists else { return nil }
72
+ let keyboardFrame = keyboard.frame
73
+ guard !keyboardFrame.isEmpty else { return nil }
74
+ return keyboardFrame
75
+ }
76
+ #else
77
+ return nil
78
+ #endif
79
+ }
80
+
81
+ private func pressKeyboardReturn(
82
+ on element: XCUIElement,
83
+ app: XCUIApplication,
84
+ wasVisible: Bool
85
+ ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
86
+ #if os(iOS)
87
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
88
+ element.tap()
89
+ element.typeText(XCUIKeyboardKey.return.rawValue)
90
+ })
91
+ if let exceptionMessage {
92
+ NSLog(
93
+ "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
94
+ exceptionMessage
95
+ )
96
+ return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
97
+ }
98
+ sleepFor(0.2)
99
+ return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
100
+ #else
101
+ return (wasVisible: wasVisible, pressed: false, visible: false)
102
+ #endif
103
+ }
104
+
105
+ private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
106
+ #if os(iOS)
107
+ let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
108
+ app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
109
+ guard element.exists else { return false }
110
+ switch element.elementType {
111
+ case .textField, .secureTextField, .searchField, .textView:
112
+ return true
113
+ default:
114
+ return false
115
+ }
116
+ }
117
+ }
118
+ return matches.count == 1 ? matches[0] : nil
119
+ #else
120
+ return nil
121
+ #endif
122
+ }
123
+
124
+ private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
125
+ #if os(tvOS)
126
+ return false
127
+ #else
128
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
129
+ return false
130
+ }
131
+ for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
132
+ let candidates = [
133
+ app.keyboards.buttons[label],
134
+ app.keyboards.keys[label],
135
+ app.keyboards.toolbars.buttons[label],
136
+ ]
137
+ if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
138
+ hittable.tap()
139
+ return true
140
+ }
141
+
142
+ let toolbarButtonPredicate = NSPredicate(
143
+ format: "label == %@ OR identifier == %@",
144
+ label,
145
+ label
146
+ )
147
+ let toolbarButtons = app.descendants(matching: .button)
148
+ .matching(toolbarButtonPredicate)
149
+ .allElementsBoundByIndex
150
+ if let hittable = toolbarButtons.first(where: {
151
+ $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
152
+ }) {
153
+ hittable.tap()
154
+ return true
155
+ }
156
+ }
157
+ return false
158
+ #endif
159
+ }
160
+
161
+ private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
162
+ #if os(iOS)
163
+ for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
164
+ let candidates = [
165
+ app.keyboards.buttons[label],
166
+ app.keyboards.keys[label],
167
+ ]
168
+ if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
169
+ hittable.tap()
170
+ return true
171
+ }
172
+ }
173
+ #endif
174
+ return false
175
+ }
176
+
177
+ private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
178
+ let frame = element.frame
179
+ guard !frame.isEmpty && !keyboardFrame.isEmpty else {
180
+ return false
181
+ }
182
+ return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
183
+ }
184
+ }
@@ -0,0 +1,92 @@
1
+ import XCTest
2
+
3
+ private enum OrientationObservationTiming {
4
+ static let pollInterval: TimeInterval = 0.05
5
+ static let timeout: TimeInterval = 2
6
+ }
7
+
8
+ extension RunnerTests {
9
+ func executeRotateCommand(_ command: Command) -> Response {
10
+ guard let orientation = command.orientation?.trimmingCharacters(in: .whitespacesAndNewlines),
11
+ !orientation.isEmpty
12
+ else {
13
+ return Response(ok: false, error: ErrorPayload(message: "rotate requires orientation"))
14
+ }
15
+ let supportedOrientations = [
16
+ "portrait",
17
+ "portrait-upside-down",
18
+ "landscape-left",
19
+ "landscape-right",
20
+ ]
21
+ guard supportedOrientations.contains(orientation) else {
22
+ return Response(
23
+ ok: false,
24
+ error: ErrorPayload(message: "unsupported rotate orientation: \(orientation)")
25
+ )
26
+ }
27
+ guard let observedOrientation = rotateDevice(to: orientation) else {
28
+ return Response(
29
+ ok: false,
30
+ error: ErrorPayload(message: "unable to observe device orientation after rotate")
31
+ )
32
+ }
33
+ guard observedOrientation == orientation else {
34
+ return Response(
35
+ ok: false,
36
+ error: ErrorPayload(
37
+ message: "device orientation is \(observedOrientation), expected \(orientation)"
38
+ )
39
+ )
40
+ }
41
+ return Response(
42
+ ok: true,
43
+ data: DataPayload(message: "rotate", orientation: observedOrientation)
44
+ )
45
+ }
46
+
47
+ private func rotateDevice(to orientationName: String) -> String? {
48
+ #if os(macOS) || os(tvOS) || os(visionOS)
49
+ return nil
50
+ #else
51
+ switch orientationName {
52
+ case "portrait":
53
+ XCUIDevice.shared.orientation = .portrait
54
+ case "portrait-upside-down":
55
+ XCUIDevice.shared.orientation = .portraitUpsideDown
56
+ case "landscape-left":
57
+ XCUIDevice.shared.orientation = .landscapeLeft
58
+ case "landscape-right":
59
+ XCUIDevice.shared.orientation = .landscapeRight
60
+ default:
61
+ return nil
62
+ }
63
+ let deadline = Date().addingTimeInterval(OrientationObservationTiming.timeout)
64
+ repeat {
65
+ if observedDeviceOrientation() == orientationName {
66
+ return orientationName
67
+ }
68
+ sleepFor(OrientationObservationTiming.pollInterval)
69
+ } while Date() < deadline
70
+ return observedDeviceOrientation()
71
+ #endif
72
+ }
73
+
74
+ private func observedDeviceOrientation() -> String? {
75
+ #if os(macOS) || os(tvOS) || os(visionOS)
76
+ return nil
77
+ #else
78
+ switch XCUIDevice.shared.orientation {
79
+ case .portrait:
80
+ return "portrait"
81
+ case .portraitUpsideDown:
82
+ return "portrait-upside-down"
83
+ case .landscapeLeft:
84
+ return "landscape-left"
85
+ case .landscapeRight:
86
+ return "landscape-right"
87
+ default:
88
+ return nil
89
+ }
90
+ #endif
91
+ }
92
+ }