agent-device 0.20.2 → 0.20.4

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 (141) hide show
  1. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.2.apk → agent-device-android-ime-helper-0.20.4.apk} +0 -0
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.4.apk.sha256 +1 -0
  3. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.2.manifest.json → agent-device-android-ime-helper-0.20.4.manifest.json} +4 -4
  4. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk +0 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk.sha256 +1 -0
  6. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.2.manifest.json → agent-device-android-snapshot-helper-0.20.4.manifest.json} +6 -6
  7. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +57 -4
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +45 -185
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +257 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +1 -0
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +3 -2
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +1 -1
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +8 -2
  14. package/dist/src/agent-browser-tool.js +1 -1
  15. package/dist/src/agent-device-client.js +2 -2
  16. package/dist/src/android-adb.d.ts +4 -27
  17. package/dist/src/android-adb.js +1 -1
  18. package/dist/src/android.js +1 -1
  19. package/dist/src/app-helpers.js +1 -0
  20. package/dist/src/app-inventory.d.ts +4 -0
  21. package/dist/src/app-lifecycle.js +7 -7
  22. package/dist/src/app-resolution.js +1 -1
  23. package/dist/src/apps.js +5 -5
  24. package/dist/src/boot-diagnostics.js +1 -1
  25. package/dist/src/cli-config.js +1 -1
  26. package/dist/src/cli-help.js +27 -19
  27. package/dist/src/cli-runner.js +1 -1
  28. package/dist/src/cli.js +15 -14
  29. package/dist/src/client-metro.js +1 -1
  30. package/dist/src/{tv-remote.d.ts → cloud-artifacts.d.ts} +109 -126
  31. package/dist/src/command-metadata.js +1 -1
  32. package/dist/src/command-schema.js +1 -1
  33. package/dist/src/command-surface.js +1 -1
  34. package/dist/src/context.js +1 -1
  35. package/dist/src/contracts.d.ts +1 -1
  36. package/dist/src/debug-symbols.js +4 -4
  37. package/dist/src/device-input-state.d.ts +26 -0
  38. package/dist/src/device-input-state.js +1 -1
  39. package/dist/src/device.js +1 -9
  40. package/dist/src/devices2.js +1 -1
  41. package/dist/src/entities.js +1 -0
  42. package/dist/src/find.js +1 -1
  43. package/dist/src/finders.d.ts +1 -1
  44. package/dist/src/finders.js +1 -1
  45. package/dist/src/generic.js +1 -1
  46. package/dist/src/helper.js +2 -2
  47. package/dist/src/ime-lifecycle.js +9 -0
  48. package/dist/src/index.d.ts +593 -556
  49. package/dist/src/input-actions.js +4 -4
  50. package/dist/src/install-artifact.js +4 -4
  51. package/dist/src/install-artifact2.js +1 -1
  52. package/dist/src/install-source2.js +1 -1
  53. package/dist/src/interaction-snapshot.js +1 -1
  54. package/dist/src/interaction.d.ts +7 -4
  55. package/dist/src/interaction.js +1 -1
  56. package/dist/src/interactor.js +2 -2
  57. package/dist/src/interactor2.js +1 -1
  58. package/dist/src/internal/bin.js +1 -1
  59. package/dist/src/internal/daemon.js +14 -14
  60. package/dist/src/internal/png-worker.js +1 -1
  61. package/dist/src/lease-context.js +1 -1
  62. package/dist/src/lease.js +1 -1
  63. package/dist/src/limrun.d.ts +171 -190
  64. package/dist/src/limrun.js +1 -1
  65. package/dist/src/linux.js +1 -1
  66. package/dist/src/logcat.js +1 -1
  67. package/dist/src/manifest.js +1 -1
  68. package/dist/src/metro.d.ts +1 -1
  69. package/dist/src/parser.js +2 -0
  70. package/dist/src/path-resolution.js +1 -1
  71. package/dist/src/physical-device-control.js +1 -1
  72. package/dist/src/plist-xml.js +1 -0
  73. package/dist/src/plist.js +1 -1
  74. package/dist/src/png-worker-contract.js +1 -1
  75. package/dist/src/process-lock.js +1 -1
  76. package/dist/src/provider-limrun-runtime.js +1 -0
  77. package/dist/src/react-native.js +1 -1
  78. package/dist/src/record-trace.js +1 -1
  79. package/dist/src/register-builtins.js +1 -1
  80. package/dist/src/registry.js +22 -22
  81. package/dist/src/remote-config2.js +1 -1
  82. package/dist/src/reporting.js +7 -7
  83. package/dist/src/runner-client.js +1 -1
  84. package/dist/src/runner-disposal.js +1 -1
  85. package/dist/src/runner-lease.js +1 -1
  86. package/dist/src/runner-provider.js +1 -1
  87. package/dist/src/runner-sequence.js +1 -1
  88. package/dist/src/runtime-transport.js +1 -1
  89. package/dist/src/runtime.js +3 -3
  90. package/dist/src/screenshot-geometry.js +1 -0
  91. package/dist/src/screenshot-result.js +6 -0
  92. package/dist/src/sdk-android-adb.d.ts +5 -6
  93. package/dist/src/sdk-android-adb.js +3 -3
  94. package/dist/src/sdk-batch-runner.d.ts +2 -2
  95. package/dist/src/sdk-batch-runner.js +3 -3
  96. package/dist/src/sdk-contracts.d.ts +21 -25
  97. package/dist/src/sdk-contracts.js +2 -2
  98. package/dist/src/sdk-finders.d.ts +1 -1
  99. package/dist/src/sdk-finders.js +1 -1
  100. package/dist/src/sdk-io.js +1 -1
  101. package/dist/src/sdk-remote-config.d.ts +19 -4
  102. package/dist/src/sdk-remote-config.js +1 -1
  103. package/dist/src/sdk-selectors.js +1 -1
  104. package/dist/src/selector-runtime.js +1 -1
  105. package/dist/src/selector-vocabulary.js +1 -1
  106. package/dist/src/selectors.d.ts +1 -1
  107. package/dist/src/selectors.js +1 -1
  108. package/dist/src/server.js +2 -2
  109. package/dist/src/session-target-evidence.js +1 -1
  110. package/dist/src/session.js +8 -8
  111. package/dist/src/setting-state.js +1 -1
  112. package/dist/src/simulator.js +1 -1
  113. package/dist/src/snapshot.js +1 -1
  114. package/dist/src/snapshot2.js +1 -1
  115. package/dist/src/src.js +1 -0
  116. package/dist/src/tool-provider.js +3 -3
  117. package/dist/src/tool-provider2.js +1 -1
  118. package/dist/src/tool-provider3.js +1 -1
  119. package/dist/src/update-check.js +1 -1
  120. package/dist/src/version.js +1 -1
  121. package/dist/src/viewport-dimension.js +14 -0
  122. package/package.json +28 -19
  123. package/server.json +2 -2
  124. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.2.apk.sha256 +0 -1
  125. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.2.apk +0 -0
  126. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.2.apk.sha256 +0 -1
  127. package/dist/src/android-input-method-overlays.js +0 -1
  128. package/dist/src/android-input-ownership.js +0 -1
  129. package/dist/src/apple-multitouch-support.js +0 -1
  130. package/dist/src/click-button.d.ts +0 -5
  131. package/dist/src/compatibility-policy.js +0 -1
  132. package/dist/src/gesture-plan-types.js +0 -1
  133. package/dist/src/gesture-plan.js +0 -1
  134. package/dist/src/launch-console.js +0 -1
  135. package/dist/src/recording-export-quality.js +0 -15
  136. package/dist/src/rolldown-runtime.js +0 -1
  137. package/dist/src/runtime2.js +0 -1
  138. package/dist/src/sdk-app-inventory.d.ts +0 -4
  139. package/dist/src/sdk-app-inventory.js +0 -1
  140. package/dist/src/snapshot-diagnostics.js +0 -6
  141. package/dist/src/xml.js +0 -2
@@ -0,0 +1 @@
1
+ 019ebbda52976df5cff05f9e5a764c8d6e280be3640e8dea0a2d7cca6c25021f agent-device-android-ime-helper-0.20.4.apk
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "android-ime-helper",
3
- "version": "0.20.2",
4
- "assetName": "agent-device-android-ime-helper-0.20.2.apk",
5
- "sha256": "941f2997c4315e753fb2f5411562a2907b3a188370c3d52e762692843be1b50f",
3
+ "version": "0.20.4",
4
+ "assetName": "agent-device-android-ime-helper-0.20.4.apk",
5
+ "sha256": "019ebbda52976df5cff05f9e5a764c8d6e280be3640e8dea0a2d7cca6c25021f",
6
6
  "packageName": "com.callstack.agentdevice.imehelper",
7
- "versionCode": 20002,
7
+ "versionCode": 20004,
8
8
  "serviceComponent": "com.callstack.agentdevice.imehelper/.TestInputMethodService",
9
9
  "broadcastProtocol": "android-ime-helper-v1"
10
10
  }
@@ -0,0 +1 @@
1
+ e3799afe5549776e047744efe46939a9146a4742a5328bb2e22f0b9d6abd2b52 agent-device-android-snapshot-helper-0.20.4.apk
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "android-snapshot-helper",
3
- "version": "0.20.2",
4
- "releaseTag": "v0.20.2",
5
- "assetName": "agent-device-android-snapshot-helper-0.20.2.apk",
3
+ "version": "0.20.4",
4
+ "releaseTag": "v0.20.4",
5
+ "assetName": "agent-device-android-snapshot-helper-0.20.4.apk",
6
6
  "apkUrl": null,
7
- "sha256": "003859ea5dc5d1ed8ddc5daf786eacb790ff471bbb0c3b0e1239712f73f37684",
8
- "checksumName": "agent-device-android-snapshot-helper-0.20.2.apk.sha256",
7
+ "sha256": "e3799afe5549776e047744efe46939a9146a4742a5328bb2e22f0b9d6abd2b52",
8
+ "checksumName": "agent-device-android-snapshot-helper-0.20.4.apk.sha256",
9
9
  "packageName": "com.callstack.agentdevice.snapshothelper",
10
- "versionCode": 20002,
10
+ "versionCode": 20004,
11
11
  "instrumentationRunner": "com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation",
12
12
  "minSdk": 23,
13
13
  "targetSdk": 36,
@@ -922,6 +922,49 @@ extension RunnerTests {
922
922
  x: touchPoint.x,
923
923
  y: touchPoint.y
924
924
  )
925
+ var fallback: GestureFallback?
926
+ if command.synthesized == true {
927
+ let policyKind = SynthesizedGesturePolicyKind.coordinateTap
928
+ let context = synthesizedCoordinateContext(
929
+ app: activeApp,
930
+ policy: synthesizedGesturePolicy(policyKind)
931
+ )
932
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
933
+ synthesizedTapAt(
934
+ app: activeApp,
935
+ x: touchPoint.x,
936
+ y: touchPoint.y,
937
+ context: context
938
+ )
939
+ }
940
+ if case .performed = outcome {
941
+ logSynthesizedGesturePolicyDecision(
942
+ kind: policyKind,
943
+ context: context,
944
+ fallbackAttempted: false
945
+ )
946
+ if isTextEntry {
947
+ waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
948
+ }
949
+ return gestureResponse(
950
+ message: match.usedNonHittableFallback
951
+ ? "tapped via non-hittable coordinate fallback"
952
+ : "tapped",
953
+ timing: timing,
954
+ frame: .touch(touchFrame),
955
+ maestroNonHittableCoordinateFallbackUsed:
956
+ command.allowNonHittableCoordinateFallback == true
957
+ ? match.usedNonHittableFallback
958
+ : nil
959
+ )
960
+ }
961
+ logSynthesizedGesturePolicyDecision(
962
+ kind: policyKind,
963
+ context: context,
964
+ fallbackAttempted: true
965
+ )
966
+ fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
967
+ }
925
968
  let (timing, outcome) = performGesture(activeApp) {
926
969
  if expectedPoint != nil || match.usedNonHittableFallback {
927
970
  // Maestro compatibility: RN E2E backdoor controls can be 1x1 and
@@ -942,6 +985,7 @@ extension RunnerTests {
942
985
  message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
943
986
  timing: timing,
944
987
  frame: .touch(touchFrame),
988
+ fallback: fallback,
945
989
  maestroNonHittableCoordinateFallbackUsed:
946
990
  command.allowNonHittableCoordinateFallback == true
947
991
  ? match.usedNonHittableFallback
@@ -954,7 +998,10 @@ extension RunnerTests {
954
998
  var fallback: GestureFallback?
955
999
  if command.synthesized == true {
956
1000
  let policyKind = SynthesizedGesturePolicyKind.coordinateTap
957
- let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(policyKind))
1001
+ let context = synthesizedCoordinateContext(
1002
+ app: activeApp,
1003
+ policy: synthesizedGesturePolicy(policyKind)
1004
+ )
958
1005
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
959
1006
  synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
960
1007
  }
@@ -1045,7 +1092,10 @@ extension RunnerTests {
1045
1092
  )
1046
1093
  }
1047
1094
  let scrollPolicyKind = SynthesizedGesturePolicyKind.scroll
1048
- guard let scrollContext = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(scrollPolicyKind)) else {
1095
+ guard let scrollContext = synthesizedCoordinateContext(
1096
+ app: activeApp,
1097
+ policy: synthesizedGesturePolicy(scrollPolicyKind)
1098
+ ) else {
1049
1099
  return Response(
1050
1100
  ok: false,
1051
1101
  error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
@@ -1439,7 +1489,10 @@ extension RunnerTests {
1439
1489
  var fallback: GestureFallback?
1440
1490
  if synthesized {
1441
1491
  let durationMs = min(max(durationMs ?? 250, 16), 10000)
1442
- let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(synthesizedPolicyKind))
1492
+ let context = synthesizedCoordinateContext(
1493
+ app: activeApp,
1494
+ policy: synthesizedGesturePolicy(synthesizedPolicyKind)
1495
+ )
1443
1496
  let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1444
1497
  synthesizedDragAt(
1445
1498
  app: activeApp,
@@ -1495,7 +1548,7 @@ extension RunnerTests {
1495
1548
  ) -> Response? {
1496
1549
  #if os(iOS)
1497
1550
  let policy = synthesizedGesturePolicy(policyKind)
1498
- let context = context ?? synthesizedCoordinateContext(policy: policy)
1551
+ let context = context ?? synthesizedCoordinateContext(app: activeApp, policy: policy)
1499
1552
  guard let plan = axFreeSynthesizedDragPlan(
1500
1553
  app: activeApp,
1501
1554
  x: x,
@@ -335,168 +335,6 @@ extension RunnerTests {
335
335
  && point.y <= frame.maxY + tolerance
336
336
  }
337
337
 
338
- func isKeyboardVisible(app: XCUIApplication) -> Bool {
339
- return visibleKeyboardFrame(app: app) != nil
340
- }
341
-
342
- func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
343
- let wasVisible = isKeyboardVisible(app: app)
344
- guard wasVisible else {
345
- return (wasVisible: false, dismissed: false, visible: false)
346
- }
347
-
348
- #if os(tvOS)
349
- _ = pressTvRemote(.menu)
350
- sleepFor(0.2)
351
- let visible = isKeyboardVisible(app: app)
352
- return (wasVisible: true, dismissed: !visible, visible: visible)
353
- #else
354
- if tapKeyboardDismissControl(app: app) {
355
- sleepFor(0.2)
356
- let visible = isKeyboardVisible(app: app)
357
- return (wasVisible: true, dismissed: !visible, visible: visible)
358
- }
359
-
360
- return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
361
- #endif
362
- }
363
-
364
- func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
365
- #if os(tvOS)
366
- return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
367
- #elseif os(iOS)
368
- let wasVisible = isKeyboardVisible(app: app)
369
- if tapKeyboardReturnControl(app: app) {
370
- sleepFor(0.2)
371
- return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
372
- }
373
-
374
- var typed = false
375
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
376
- app.typeText(XCUIKeyboardKey.return.rawValue)
377
- typed = true
378
- })
379
- if let exceptionMessage {
380
- NSLog(
381
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
382
- exceptionMessage
383
- )
384
- if let singleTarget = singleTextEntryElement(app: app) {
385
- return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
386
- }
387
- return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
388
- }
389
- sleepFor(0.2)
390
- return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
391
- #else
392
- return (wasVisible: false, pressed: false, visible: false)
393
- #endif
394
- }
395
-
396
- private func pressKeyboardReturn(
397
- on element: XCUIElement,
398
- app: XCUIApplication,
399
- wasVisible: Bool
400
- ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
401
- #if os(iOS)
402
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
403
- element.tap()
404
- element.typeText(XCUIKeyboardKey.return.rawValue)
405
- })
406
- if let exceptionMessage {
407
- NSLog(
408
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
409
- exceptionMessage
410
- )
411
- return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
412
- }
413
- sleepFor(0.2)
414
- return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
415
- #else
416
- return (wasVisible: wasVisible, pressed: false, visible: false)
417
- #endif
418
- }
419
-
420
- private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
421
- #if os(iOS)
422
- let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
423
- app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
424
- guard element.exists else { return false }
425
- switch element.elementType {
426
- case .textField, .secureTextField, .searchField, .textView:
427
- return true
428
- default:
429
- return false
430
- }
431
- }
432
- }
433
- return matches.count == 1 ? matches[0] : nil
434
- #else
435
- return nil
436
- #endif
437
- }
438
-
439
- private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
440
- #if os(tvOS)
441
- return false
442
- #else
443
- guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
444
- return false
445
- }
446
- for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
447
- let candidates = [
448
- app.keyboards.buttons[label],
449
- app.keyboards.keys[label],
450
- app.keyboards.toolbars.buttons[label],
451
- ]
452
- if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
453
- hittable.tap()
454
- return true
455
- }
456
-
457
- let toolbarButtonPredicate = NSPredicate(
458
- format: "label == %@ OR identifier == %@",
459
- label,
460
- label
461
- )
462
- let toolbarButtons = app.descendants(matching: .button)
463
- .matching(toolbarButtonPredicate)
464
- .allElementsBoundByIndex
465
- if let hittable = toolbarButtons.first(where: {
466
- $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
467
- }) {
468
- hittable.tap()
469
- return true
470
- }
471
- }
472
- return false
473
- #endif
474
- }
475
-
476
- private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
477
- #if os(iOS)
478
- for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
479
- let candidates = [
480
- app.keyboards.buttons[label],
481
- app.keyboards.keys[label],
482
- ]
483
- if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
484
- hittable.tap()
485
- return true
486
- }
487
- }
488
- #endif
489
- return false
490
- }
491
-
492
- private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
493
- let frame = element.frame
494
- guard !frame.isEmpty && !keyboardFrame.isEmpty else {
495
- return false
496
- }
497
- return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
498
- }
499
-
500
338
  private func readableText(for element: XCUIElement) -> String? {
501
339
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
502
340
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -779,7 +617,10 @@ extension RunnerTests {
779
617
  )
780
618
  }
781
619
  let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
782
- guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag)) else {
620
+ guard let context = context ?? synthesizedCoordinateContext(
621
+ app: app,
622
+ policy: synthesizedGesturePolicy(.synthesizedDrag)
623
+ ) else {
783
624
  return .unsupported(
784
625
  message: "synthesized coordinate drag could not resolve a finite screen frame",
785
626
  hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
@@ -842,7 +683,10 @@ extension RunnerTests {
842
683
  )
843
684
  }
844
685
  let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
845
- guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.coordinateTap)) else {
686
+ guard let context = context ?? synthesizedCoordinateContext(
687
+ app: app,
688
+ policy: synthesizedGesturePolicy(.coordinateTap)
689
+ ) else {
846
690
  return .unsupported(
847
691
  message: "synthesized coordinate tap could not resolve a finite screen frame",
848
692
  hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
@@ -1005,20 +849,6 @@ extension RunnerTests {
1005
849
  #endif
1006
850
  }
1007
851
 
1008
- private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
1009
- #if os(iOS)
1010
- return safely("KEYBOARD_FRAME") {
1011
- let keyboard = app.keyboards.firstMatch
1012
- guard keyboard.exists else { return nil }
1013
- let keyboardFrame = keyboard.frame
1014
- guard !keyboardFrame.isEmpty else { return nil }
1015
- return keyboardFrame
1016
- }
1017
- #else
1018
- return nil
1019
- #endif
1020
- }
1021
-
1022
852
  func axFreeSynthesizedDragPlan(
1023
853
  app: XCUIApplication,
1024
854
  x: Double,
@@ -1028,7 +858,10 @@ extension RunnerTests {
1028
858
  context: SynthesizedCoordinateContext? = nil
1029
859
  ) -> SynthesizedDragPlan? {
1030
860
  #if os(iOS)
1031
- let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
861
+ let context = context ?? synthesizedCoordinateContext(
862
+ app: app,
863
+ policy: synthesizedGesturePolicy(.synthesizedDrag)
864
+ )
1032
865
  guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite,
1033
866
  let context
1034
867
  else {
@@ -1068,11 +901,18 @@ extension RunnerTests {
1068
901
  )
1069
902
  }
1070
903
 
1071
- func synthesizedCoordinateContext(policy: SynthesizedGesturePolicy) -> SynthesizedCoordinateContext? {
904
+ func synthesizedCoordinateContext(
905
+ app: XCUIApplication,
906
+ policy: SynthesizedGesturePolicy
907
+ ) -> SynthesizedCoordinateContext? {
1072
908
  #if os(iOS)
1073
909
  let health = runnerAccessibilityHealth
1074
- guard let referenceFrame = synthesizedScreenshotReferenceFrame(
1075
- screenshotSize: { XCUIScreen.main.screenshot().image.size }
910
+ let orientation = Int(
911
+ RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)
912
+ )
913
+ guard let referenceFrame = orientedSynthesizedScreenshotReferenceFrame(
914
+ screenshotSize: XCUIScreen.main.screenshot().image.size,
915
+ interfaceOrientation: orientation
1076
916
  ) else {
1077
917
  return nil
1078
918
  }
@@ -1087,15 +927,35 @@ extension RunnerTests {
1087
927
  #endif
1088
928
  }
1089
929
 
1090
- func synthesizedScreenshotReferenceFrame(screenshotSize: () -> CGSize) -> CGRect? {
1091
- let screenshotSize = screenshotSize()
930
+ func orientedSynthesizedScreenshotReferenceFrame(
931
+ screenshotSize: CGSize,
932
+ interfaceOrientation: Int
933
+ ) -> CGRect? {
934
+ // Physical iOS screenshots can retain portrait dimensions after the interface rotates,
935
+ // while accessibility frames remain in the logical landscape coordinate space.
1092
936
  guard screenshotSize.width.isFinite, screenshotSize.height.isFinite,
1093
937
  screenshotSize.width > 0,
1094
938
  screenshotSize.height > 0
1095
939
  else {
1096
940
  return nil
1097
941
  }
1098
- return CGRect(x: 0, y: 0, width: screenshotSize.width, height: screenshotSize.height)
942
+ let isLandscape = interfaceOrientation == RunnerInterfaceOrientation.landscapeLeft
943
+ || interfaceOrientation == RunnerInterfaceOrientation.landscapeRight
944
+ let isPortrait = interfaceOrientation == RunnerInterfaceOrientation.portrait
945
+ || interfaceOrientation == RunnerInterfaceOrientation.portraitUpsideDown
946
+ let width: CGFloat
947
+ let height: CGFloat
948
+ if isLandscape {
949
+ width = max(screenshotSize.width, screenshotSize.height)
950
+ height = min(screenshotSize.width, screenshotSize.height)
951
+ } else if isPortrait {
952
+ width = min(screenshotSize.width, screenshotSize.height)
953
+ height = max(screenshotSize.width, screenshotSize.height)
954
+ } else {
955
+ width = screenshotSize.width
956
+ height = screenshotSize.height
957
+ }
958
+ return CGRect(x: 0, y: 0, width: width, height: height)
1099
959
  }
1100
960
 
1101
961
  func synthesizedFrameAvoidingKeyboardWhenAllowed(
@@ -0,0 +1,257 @@
1
+ import XCTest
2
+
3
+ private enum KeyboardDismissObservationTiming {
4
+ static let timeout: TimeInterval = 2
5
+ // #1542: dismissing the keyboard can trigger the host app's own content-offset
6
+ // adjustment (e.g. a ScrollView correcting the inset it grew to keep a focused
7
+ // field above the keyboard). That adjustment is a separate, unsynchronized
8
+ // animation the keyboard's own `waitForNonExistence` above knows nothing about:
9
+ // the keyboard AX element can vanish well before the app settles. The very next
10
+ // command is frequently a synthesized, AX-free drag (scroll/gesture — kept
11
+ // AX-free so it still works under #1105-family AX degradation), which has no
12
+ // XCTest quiescence wait of its own, so it can land mid-animation and net to
13
+ // zero. These bounds cap the settle wait this trades in.
14
+ // A spring-driven content-offset correction can pass through a near-zero-
15
+ // velocity inflection (the top of an overshoot) that two adjacent samples
16
+ // alone cannot distinguish from true rest. Requiring 3 consecutive matching
17
+ // samples demands ~2 full sample intervals of actual stillness before
18
+ // stopping early, so a momentary pause mid-animation cannot look settled.
19
+ static let settleTimeout: TimeInterval = 2.0
20
+ static let settleSampleInterval: TimeInterval = 0.15
21
+ static let settleRequiredConsecutiveMatches: Int = 3
22
+ }
23
+
24
+ extension RunnerTests {
25
+ func isKeyboardVisible(app: XCUIApplication) -> Bool {
26
+ return visibleKeyboardFrame(app: app) != nil
27
+ }
28
+
29
+ func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
30
+ let keyboard = app.keyboards.firstMatch
31
+ let wasVisible = isKeyboardVisible(app: app)
32
+ guard wasVisible else {
33
+ return (wasVisible: false, dismissed: false, visible: false)
34
+ }
35
+
36
+ #if os(tvOS)
37
+ _ = pressTvRemote(.menu)
38
+ sleepFor(0.2)
39
+ let visible = isKeyboardVisible(app: app)
40
+ return (wasVisible: true, dismissed: !visible, visible: visible)
41
+ #else
42
+ if tapKeyboardDismissControl(app: app) {
43
+ _ = keyboard.waitForNonExistence(timeout: KeyboardDismissObservationTiming.timeout)
44
+ waitForScreenshotStability(
45
+ timeout: KeyboardDismissObservationTiming.settleTimeout,
46
+ sampleInterval: KeyboardDismissObservationTiming.settleSampleInterval,
47
+ requiredConsecutiveMatches: KeyboardDismissObservationTiming.settleRequiredConsecutiveMatches
48
+ )
49
+ let visible = isKeyboardVisible(app: app)
50
+ return (wasVisible: true, dismissed: !visible, visible: visible)
51
+ }
52
+
53
+ return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
54
+ #endif
55
+ }
56
+
57
+ // AX-free on purpose (screenshot bytes, not the accessibility tree) so it holds
58
+ // under the same AX degradation the synthesized gesture lane is built to survive.
59
+ // Bounded and self-terminating: returns as soon as `requiredConsecutiveMatches`
60
+ // consecutive samples match, so an already-settled screen (the common case)
61
+ // pays close to nothing. The stopping decision itself
62
+ // (`runnerScreenshotStabilitySettled`) is a pure function of the samples taken
63
+ // so far, so it is unit-testable without a real screenshot pipeline; this loop
64
+ // is the thin, untestable I/O shell around it.
65
+ private func waitForScreenshotStability(
66
+ timeout: TimeInterval,
67
+ sampleInterval: TimeInterval,
68
+ requiredConsecutiveMatches: Int
69
+ ) {
70
+ let deadline = Date().addingTimeInterval(timeout)
71
+ var samples: [Data?] = [screenshotFingerprintForStabilityCheck()]
72
+ while Date() < deadline {
73
+ sleepFor(sampleInterval)
74
+ samples.append(screenshotFingerprintForStabilityCheck())
75
+ if runnerScreenshotStabilitySettled(samples, requiredConsecutiveMatches: requiredConsecutiveMatches) {
76
+ return
77
+ }
78
+ }
79
+ }
80
+
81
+ private func screenshotFingerprintForStabilityCheck() -> Data? {
82
+ guard let image = captureRunnerFrame() else { return nil }
83
+ return runnerPngData(for: image)
84
+ }
85
+
86
+ func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
87
+ #if os(tvOS)
88
+ return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
89
+ #elseif os(iOS)
90
+ let wasVisible = isKeyboardVisible(app: app)
91
+ if tapKeyboardReturnControl(app: app) {
92
+ sleepFor(0.2)
93
+ return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
94
+ }
95
+
96
+ var typed = false
97
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
98
+ app.typeText(XCUIKeyboardKey.return.rawValue)
99
+ typed = true
100
+ })
101
+ if let exceptionMessage {
102
+ NSLog(
103
+ "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
104
+ exceptionMessage
105
+ )
106
+ if let singleTarget = singleTextEntryElement(app: app) {
107
+ return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
108
+ }
109
+ return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
110
+ }
111
+ sleepFor(0.2)
112
+ return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
113
+ #else
114
+ return (wasVisible: false, pressed: false, visible: false)
115
+ #endif
116
+ }
117
+
118
+ func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
119
+ #if os(iOS)
120
+ return safely("KEYBOARD_FRAME") {
121
+ let keyboard = app.keyboards.firstMatch
122
+ guard keyboard.exists else { return nil }
123
+ let keyboardFrame = keyboard.frame
124
+ guard !keyboardFrame.isEmpty else { return nil }
125
+ return keyboardFrame
126
+ }
127
+ #else
128
+ return nil
129
+ #endif
130
+ }
131
+
132
+ private func pressKeyboardReturn(
133
+ on element: XCUIElement,
134
+ app: XCUIApplication,
135
+ wasVisible: Bool
136
+ ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
137
+ #if os(iOS)
138
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
139
+ element.tap()
140
+ element.typeText(XCUIKeyboardKey.return.rawValue)
141
+ })
142
+ if let exceptionMessage {
143
+ NSLog(
144
+ "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
145
+ exceptionMessage
146
+ )
147
+ return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
148
+ }
149
+ sleepFor(0.2)
150
+ return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
151
+ #else
152
+ return (wasVisible: wasVisible, pressed: false, visible: false)
153
+ #endif
154
+ }
155
+
156
+ private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
157
+ #if os(iOS)
158
+ let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
159
+ app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
160
+ guard element.exists else { return false }
161
+ switch element.elementType {
162
+ case .textField, .secureTextField, .searchField, .textView:
163
+ return true
164
+ default:
165
+ return false
166
+ }
167
+ }
168
+ }
169
+ return matches.count == 1 ? matches[0] : nil
170
+ #else
171
+ return nil
172
+ #endif
173
+ }
174
+
175
+ private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
176
+ #if os(tvOS)
177
+ return false
178
+ #else
179
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
180
+ return false
181
+ }
182
+ for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
183
+ let candidates = [
184
+ app.keyboards.buttons[label],
185
+ app.keyboards.keys[label],
186
+ app.keyboards.toolbars.buttons[label],
187
+ ]
188
+ if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
189
+ hittable.tap()
190
+ return true
191
+ }
192
+
193
+ let toolbarButtonPredicate = NSPredicate(
194
+ format: "label == %@ OR identifier == %@",
195
+ label,
196
+ label
197
+ )
198
+ let toolbarButtons = app.descendants(matching: .button)
199
+ .matching(toolbarButtonPredicate)
200
+ .allElementsBoundByIndex
201
+ if let hittable = toolbarButtons.first(where: {
202
+ $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
203
+ }) {
204
+ hittable.tap()
205
+ return true
206
+ }
207
+ }
208
+ return false
209
+ #endif
210
+ }
211
+
212
+ private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
213
+ #if os(iOS)
214
+ for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
215
+ let candidates = [
216
+ app.keyboards.buttons[label],
217
+ app.keyboards.keys[label],
218
+ ]
219
+ if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
220
+ hittable.tap()
221
+ return true
222
+ }
223
+ }
224
+ #endif
225
+ return false
226
+ }
227
+
228
+ private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
229
+ let frame = element.frame
230
+ guard !frame.isEmpty && !keyboardFrame.isEmpty else {
231
+ return false
232
+ }
233
+ return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
234
+ }
235
+ }
236
+
237
+ /// True once the `requiredConsecutiveMatches` most recent samples are all present
238
+ /// and byte-identical — i.e. the screen held still across that whole run of
239
+ /// polls, not just the last two. A spring-driven content-offset correction can
240
+ /// pass through a near-zero-velocity inflection that two adjacent samples alone
241
+ /// cannot distinguish from true rest; requiring a longer run of matches demands
242
+ /// real elapsed stillness before stopping early. `nil` entries (a screenshot
243
+ /// capture that failed) never count toward a match, so a flaky capture cannot
244
+ /// look like stability; the caller's bounded loop still terminates on its
245
+ /// deadline regardless.
246
+ func runnerScreenshotStabilitySettled(
247
+ _ samples: [Data?],
248
+ requiredConsecutiveMatches: Int
249
+ ) -> Bool {
250
+ guard requiredConsecutiveMatches >= 2, samples.count >= requiredConsecutiveMatches else {
251
+ return false
252
+ }
253
+ let window = samples.suffix(requiredConsecutiveMatches)
254
+ guard let first = window.first, let firstData = first else { return false }
255
+ return window.allSatisfy { $0 == firstData }
256
+ }
257
+