agent-device 0.16.7 → 0.16.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 (43) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.7.apk → agent-device-android-multitouch-helper-0.16.9.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.9.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.7.manifest.json → agent-device-android-multitouch-helper-0.16.9.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.7.apk → agent-device-android-snapshot-helper-0.16.9.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.9.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.7.manifest.json → agent-device-android-snapshot-helper-0.16.9.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/2415.js +29 -29
  9. package/dist/src/2805.js +1 -1
  10. package/dist/src/6232.js +1 -0
  11. package/dist/src/7455.js +1 -0
  12. package/dist/src/8114.js +3 -3
  13. package/dist/src/8699.js +1 -1
  14. package/dist/src/940.js +1 -1
  15. package/dist/src/9471.js +1 -1
  16. package/dist/src/9533.js +1 -1
  17. package/dist/src/9542.js +1 -1
  18. package/dist/src/9818.js +1 -1
  19. package/dist/src/android-adb.d.ts +2 -0
  20. package/dist/src/android-snapshot-helper.d.ts +2 -0
  21. package/dist/src/args.js +5 -4
  22. package/dist/src/cli.js +6 -6
  23. package/dist/src/command-metadata.js +1 -1
  24. package/dist/src/find.js +1 -1
  25. package/dist/src/generic.js +11 -7
  26. package/dist/src/interaction.js +1 -1
  27. package/dist/src/react-native.js +1 -1
  28. package/dist/src/record-trace.js +3 -3
  29. package/dist/src/selector-runtime.js +1 -1
  30. package/dist/src/session.js +9 -9
  31. package/dist/src/snapshot.js +2 -2
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +20 -6
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +141 -774
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -33
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +71 -1
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +80 -10
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +743 -0
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +34 -6
  39. package/package.json +4 -6
  40. package/server.json +2 -2
  41. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.7.apk.sha256 +0 -1
  42. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.7.apk.sha256 +0 -1
  43. package/dist/src/5186.js +0 -1
@@ -30,48 +30,6 @@ extension RunnerTests {
30
30
  let usedNonHittableFallback: Bool
31
31
  }
32
32
 
33
- enum TextTypingRepairMode {
34
- case none
35
- case append
36
- case replacement
37
- }
38
-
39
- enum TextEntryTiming {
40
- static let focusTimeout: TimeInterval = 0.4
41
- static let repairReadinessTimeout: TimeInterval = 1.0
42
- static let readinessTimeout: TimeInterval = 2.0
43
- static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
44
- static let pollInterval: TimeInterval = 0.02
45
- static let warmupValueTimeout: TimeInterval = 0.4
46
- static let verificationStabilityWindow: TimeInterval = 0.2
47
- }
48
-
49
- struct TextEntryResult {
50
- let verified: Bool?
51
- let repaired: Bool
52
- let expectedText: String?
53
- let observedText: String?
54
- }
55
-
56
- struct TextEntryTarget {
57
- let element: XCUIElement?
58
- let refreshPoint: CGPoint?
59
- let prefersFocusedElement: Bool
60
-
61
- func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
62
- guard let nextElement else {
63
- return self
64
- }
65
- let frame = nextElement.frame
66
- let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
67
- return TextEntryTarget(
68
- element: nextElement,
69
- refreshPoint: point,
70
- prefersFocusedElement: prefersFocusedElement
71
- )
72
- }
73
- }
74
-
75
33
  // MARK: - Navigation Gestures
76
34
 
77
35
  func tapInAppBackControl(app: XCUIApplication) -> Bool {
@@ -283,6 +241,13 @@ extension RunnerTests {
283
241
 
284
242
  func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
285
243
  let point = CGPoint(x: x, y: y)
244
+ let textInputCandidates = textInputCandidatesAt(app: app, point: point)
245
+ for element in textInputCandidates where prefersExpandedTextRead(element) {
246
+ if let text = readableText(for: element) {
247
+ return text
248
+ }
249
+ }
250
+
286
251
  let candidates = app.descendants(matching: .any).allElementsBoundByIndex
287
252
  .filter { element in
288
253
  element.exists && !element.frame.isEmpty && element.frame.contains(point)
@@ -315,30 +280,29 @@ extension RunnerTests {
315
280
  return nil
316
281
  }
317
282
 
318
- func clearTextInput(_ element: XCUIElement) {
319
- #if !os(tvOS)
320
- moveCaretToEnd(element: element)
321
- #endif
322
- let count = estimatedDeleteCount(for: element)
323
- let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
324
- element.typeText(deletes)
283
+ func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
284
+ return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
325
285
  }
326
286
 
327
- func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
328
- let point = CGPoint(x: x, y: y)
329
- var matched: XCUIElement?
287
+ private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
288
+ var candidates: [XCUIElement] = []
330
289
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
290
+ // Query the text-input element types directly instead of enumerating the entire tree
291
+ // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
292
+ // slower — it dominated fill latency because resolveTextEntryElement re-runs this on
293
+ // each verify/repair poll once the focused field reference goes stale).
331
294
  // Prefer the smallest matching field so nested editable controls win over large containers.
332
- let candidates = app.descendants(matching: .any).allElementsBoundByIndex
295
+ candidates = [
296
+ app.textFields,
297
+ app.secureTextFields,
298
+ app.searchFields,
299
+ app.textViews,
300
+ ]
301
+ .flatMap { $0.allElementsBoundByIndex }
333
302
  .filter { element in
334
303
  guard element.exists else { return false }
335
- switch element.elementType {
336
- case .textField, .secureTextField, .searchField, .textView:
337
- let frame = element.frame
338
- return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
339
- default:
340
- return false
341
- }
304
+ let frame = element.frame
305
+ return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
342
306
  }
343
307
  .sorted { left, right in
344
308
  let leftArea = max(1, left.frame.width * left.frame.height)
@@ -354,16 +318,15 @@ extension RunnerTests {
354
318
  }
355
319
  return left.elementType.rawValue < right.elementType.rawValue
356
320
  }
357
- matched = candidates.first
358
321
  })
359
322
  if let exceptionMessage {
360
323
  NSLog(
361
324
  "AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
362
325
  exceptionMessage
363
326
  )
364
- return nil
327
+ return []
365
328
  }
366
- return matched
329
+ return candidates
367
330
  }
368
331
 
369
332
  private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
@@ -373,586 +336,10 @@ extension RunnerTests {
373
336
  && point.y <= frame.maxY + tolerance
374
337
  }
375
338
 
376
- func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
377
- #if os(iOS)
378
- // iOS focus predicates can return stale or misleading text-input matches
379
- // under XCUITest, so text entry readiness is driven by tap/keyboard state.
380
- return nil
381
- #else
382
- var focused: XCUIElement?
383
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
384
- let candidates = app
385
- .descendants(matching: .any)
386
- .matching(NSPredicate(format: "hasKeyboardFocus == 1"))
387
- .allElementsBoundByIndex
388
- for candidate in candidates where candidate.exists {
389
- switch candidate.elementType {
390
- case .textField, .secureTextField, .searchField, .textView:
391
- focused = candidate
392
- return
393
- default:
394
- continue
395
- }
396
- }
397
- })
398
- if let exceptionMessage {
399
- NSLog(
400
- "AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
401
- exceptionMessage
402
- )
403
- return nil
404
- }
405
- return focused
406
- #endif
407
- }
408
-
409
- func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
410
- #if os(tvOS)
411
- return target
412
- #else
413
- let latest = target
414
- let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
415
- while Date() < deadline {
416
- if let focused = focusedTextInput(app: app) {
417
- return focused
418
- }
419
- sleepFor(TextEntryTiming.pollInterval)
420
- }
421
- return latest
422
- #endif
423
- }
424
-
425
- func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
426
- guard let x, let y else {
427
- let focused = waitForTextEntryReadiness(
428
- app: app,
429
- target: TextEntryTarget(
430
- element: focusedTextInput(app: app),
431
- refreshPoint: nil,
432
- prefersFocusedElement: true
433
- )
434
- )
435
- return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
436
- }
437
-
438
- let target = textInputAt(app: app, x: x, y: y)
439
- let requestedPoint = CGPoint(x: x, y: y)
440
- if let target {
441
- let frame = target.frame
442
- if !frame.isEmpty {
443
- _ = tapAt(app: app, x: frame.midX, y: frame.midY)
444
- } else {
445
- _ = tapAt(app: app, x: x, y: y)
446
- }
447
- } else {
448
- _ = tapAt(app: app, x: x, y: y)
449
- }
450
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
451
- let element = waitForTextEntryReadiness(
452
- app: app,
453
- target: TextEntryTarget(
454
- element: stabilized ?? target,
455
- refreshPoint: requestedPoint,
456
- prefersFocusedElement: false
457
- )
458
- ) ?? stabilized ?? target
459
- return TextEntryTarget(
460
- element: element,
461
- refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
462
- prefersFocusedElement: false
463
- )
464
- }
465
-
466
- func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
467
- let point = textEntryRefreshPoint(for: element)
468
- if let point {
469
- _ = tapAt(app: app, x: point.x, y: point.y)
470
- }
471
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
472
- let resolved = waitForTextEntryReadiness(
473
- app: app,
474
- target: TextEntryTarget(
475
- element: stabilized ?? element,
476
- refreshPoint: point,
477
- prefersFocusedElement: false
478
- )
479
- ) ?? stabilized ?? element
480
- return TextEntryTarget(
481
- element: resolved,
482
- refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
483
- prefersFocusedElement: false
484
- )
485
- }
486
-
487
- func isTextEntryElement(_ element: XCUIElement) -> Bool {
488
- switch element.elementType {
489
- case .textField, .secureTextField, .searchField, .textView:
490
- return true
491
- default:
492
- return false
493
- }
494
- }
495
-
496
- func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
497
- switch command.textEntryMode {
498
- case "append":
499
- return .append
500
- case "replace":
501
- return .replacement
502
- default:
503
- return command.clearFirst == true ? .replacement : .none
504
- }
505
- }
506
-
507
- func typeTextReliably(
508
- app: XCUIApplication,
509
- target: TextEntryTarget,
510
- text: String,
511
- delaySeconds: Double,
512
- repairMode: TextTypingRepairMode = .none
513
- ) -> TextEntryResult {
514
- guard !text.isEmpty else {
515
- return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
516
- }
517
- var activeTarget = target
518
- let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
519
- activeTarget = activeTarget.withElement(initialTarget)
520
- let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
521
- let initialText = repairMode == .append ? currentText : nil
522
- let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
523
-
524
- if repairMode == .replacement {
525
- guard let replacementTarget = initialTarget else {
526
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
527
- }
528
- if currentText == nil || currentText?.isEmpty == false {
529
- clearTextInput(replacementTarget)
530
- activeTarget = activeTarget.withElement(replacementTarget)
531
- }
532
- }
533
-
534
- func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
535
- if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
536
- app.typeText(value)
537
- return currentTarget
538
- } else {
539
- app.typeText(value)
540
- return resolveTextEntryElement(app: app, target: activeTarget)
541
- }
542
- }
543
-
544
- func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
545
- guard let expectedValue else {
546
- sleepFor(TextEntryTiming.pollInterval)
547
- return
548
- }
549
- let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
550
- while Date() < deadline {
551
- if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
552
- return
553
- }
554
- sleepFor(TextEntryTiming.pollInterval)
555
- }
556
- }
557
-
558
- let characters = Array(text)
559
- if delaySeconds > 0 && characters.count > 1 {
560
- var typedTarget: XCUIElement?
561
- for (index, character) in characters.enumerated() {
562
- typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
563
- if index + 1 < characters.count {
564
- sleepFor(delaySeconds)
565
- }
566
- }
567
- if repairMode == .none {
568
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
569
- }
570
- let repairResult = repairTextEntryIfNeeded(
571
- app: app,
572
- target: activeTarget.withElement(typedTarget),
573
- expectedText: expectedText,
574
- repairMode: repairMode
575
- )
576
- return verifyTextEntry(
577
- app: app,
578
- target: activeTarget.withElement(typedTarget),
579
- expectedText: expectedText,
580
- repaired: repairResult.repaired
581
- )
582
- }
583
-
584
- let typedTarget: XCUIElement?
585
- if repairMode != .none && characters.count > 1 {
586
- let firstCharacter = String(characters[0])
587
- var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
588
- activeTarget = activeTarget.withElement(firstTypedTarget)
589
- let warmupExpectedText = expectedTextEntryValue(
590
- typedText: firstCharacter,
591
- mode: repairMode,
592
- initialText: initialText
593
- )
594
- waitForWarmupValue(warmupExpectedText, target: activeTarget)
595
- let remainingText = String(characters.dropFirst())
596
- firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
597
- typedTarget = firstTypedTarget
598
- } else {
599
- typedTarget = typeIntoCurrentTarget(text)
600
- }
601
- if repairMode == .none {
602
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
603
- }
604
- let repairResult = repairTextEntryIfNeeded(
605
- app: app,
606
- target: activeTarget.withElement(typedTarget),
607
- expectedText: expectedText,
608
- repairMode: repairMode
609
- )
610
- return verifyTextEntry(
611
- app: app,
612
- target: activeTarget.withElement(typedTarget),
613
- expectedText: expectedText,
614
- repaired: repairResult.repaired
615
- )
616
- }
617
-
618
- private func repairTextEntryIfNeeded(
619
- app: XCUIApplication,
620
- target: TextEntryTarget,
621
- expectedText: String?,
622
- repairMode: TextTypingRepairMode
623
- ) -> TextEntryResult {
624
- #if os(iOS)
625
- guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
626
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
627
- }
628
- guard let expectedText else {
629
- let observedText = editableTextValue(for: targetElement)
630
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
631
- }
632
- guard shouldRepairTextEntry(
633
- app: app,
634
- target: target,
635
- expectedText: expectedText,
636
- repairMode: repairMode
637
- ) else {
638
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
639
- }
640
-
641
- guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
642
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
643
- }
644
- let observedText = editableTextValue(for: repairTarget) ?? ""
645
- NSLog(
646
- "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
647
- expectedText.count,
648
- observedText.count
649
- )
650
- clearTextInput(repairTarget)
651
- app.typeText(expectedText)
652
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
653
- #else
654
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
655
- #endif
656
- }
657
-
658
- private func verifyTextEntry(
659
- app: XCUIApplication,
660
- target: TextEntryTarget,
661
- expectedText: String?,
662
- repaired: Bool
663
- ) -> TextEntryResult {
664
- let targetElement = resolveTextEntryElement(app: app, target: target)
665
- guard let expectedText else {
666
- return TextEntryResult(
667
- verified: nil,
668
- repaired: repaired,
669
- expectedText: nil,
670
- observedText: editableTextValue(for: targetElement)
671
- )
672
- }
673
- guard let observedText = editableTextValue(for: targetElement) else {
674
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
675
- }
676
- guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
677
- return TextEntryResult(
678
- verified: false,
679
- repaired: repaired,
680
- expectedText: expectedText,
681
- observedText: observedText
682
- )
683
- }
684
- let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
685
- var latestObservedText = observedText
686
- while Date() < stableDeadline {
687
- sleepFor(TextEntryTiming.pollInterval)
688
- guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
689
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
690
- }
691
- latestObservedText = nextObservedText
692
- guard textEntryValueMatchesExpected(
693
- resolveTextEntryElement(app: app, target: target),
694
- observedText: nextObservedText,
695
- expectedText: expectedText
696
- ) else {
697
- return TextEntryResult(
698
- verified: false,
699
- repaired: repaired,
700
- expectedText: expectedText,
701
- observedText: nextObservedText
702
- )
703
- }
704
- }
705
- return TextEntryResult(
706
- verified: true,
707
- repaired: repaired,
708
- expectedText: expectedText,
709
- observedText: latestObservedText
710
- )
711
- }
712
-
713
- private func textEntryValueMatchesExpected(
714
- _ element: XCUIElement?,
715
- observedText: String,
716
- expectedText: String
717
- ) -> Bool {
718
- if observedText == expectedText {
719
- return true
720
- }
721
- guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
722
- return false
723
- }
724
- var submittedText = expectedText
725
- while hasTextEntrySubmitSuffix(submittedText) {
726
- submittedText.removeLast()
727
- }
728
- return observedText == submittedText
729
- }
730
-
731
- private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
732
- text.hasSuffix("\n") || text.hasSuffix("\r")
733
- }
734
-
735
- private func expectedTextEntryValue(
736
- typedText: String,
737
- mode: TextTypingRepairMode,
738
- initialText: String?
739
- ) -> String? {
740
- switch mode {
741
- case .none:
742
- return nil
743
- case .append:
744
- guard let initialText else {
745
- return nil
746
- }
747
- return initialText + typedText
748
- case .replacement:
749
- return typedText
750
- }
751
- }
752
-
753
- private func shouldRepairTextEntry(
754
- app: XCUIApplication,
755
- target: TextEntryTarget,
756
- expectedText: String,
757
- repairMode: TextTypingRepairMode
758
- ) -> Bool {
759
- #if os(iOS)
760
- var latestObservedText: String?
761
- let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
762
- repeat {
763
- guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
764
- return false
765
- }
766
- if textEntryValueMatchesExpected(
767
- resolveTextEntryElement(app: app, target: target),
768
- observedText: observedText,
769
- expectedText: expectedText
770
- ) {
771
- return false
772
- }
773
- latestObservedText = observedText
774
- if !isRepairableTextEntryMismatch(
775
- observedText: observedText,
776
- expectedText: expectedText,
777
- repairMode: repairMode
778
- ) {
779
- return false
780
- }
781
- sleepFor(TextEntryTiming.pollInterval)
782
- } while Date() < deadline
783
-
784
- guard let latestObservedText else {
785
- return false
786
- }
787
- guard !textEntryValueMatchesExpected(
788
- resolveTextEntryElement(app: app, target: target),
789
- observedText: latestObservedText,
790
- expectedText: expectedText
791
- ) else {
792
- return false
793
- }
794
- return isRepairableTextEntryMismatch(
795
- observedText: latestObservedText,
796
- expectedText: expectedText,
797
- repairMode: repairMode
798
- )
799
- #else
800
- return false
801
- #endif
802
- }
803
-
804
- private func isRepairableTextEntryMismatch(
805
- observedText: String,
806
- expectedText: String,
807
- repairMode: TextTypingRepairMode
808
- ) -> Bool {
809
- guard observedText != expectedText else {
810
- return false
811
- }
812
- if repairMode == .replacement {
813
- return true
814
- }
815
- return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
816
- observedText: observedText,
817
- expectedText: expectedText
818
- )
819
- }
820
-
821
- private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
822
- guard observedText.count < expectedText.count else {
823
- return false
824
- }
825
- let missingCharacterCount = expectedText.count - observedText.count
826
- guard missingCharacterCount <= max(2, expectedText.count / 4) else {
827
- return false
828
- }
829
- var expectedIndex = expectedText.startIndex
830
- for character in observedText {
831
- guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
832
- return false
833
- }
834
- expectedIndex = expectedText.index(after: matchIndex)
835
- }
836
- return true
837
- }
838
-
839
- private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
840
- if target.prefersFocusedElement {
841
- if let focused = focusedTextInput(app: app) {
842
- return focused
843
- }
844
- if let element = target.element, element.exists {
845
- return element
846
- }
847
- } else {
848
- if let element = target.element, element.exists {
849
- return element
850
- }
851
- }
852
- if let refreshPoint = target.refreshPoint,
853
- let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
854
- return refreshed
855
- }
856
- if let focused = focusedTextInput(app: app) {
857
- return focused
858
- }
859
- return nil
860
- }
861
-
862
- private func waitForTextEntryReadiness(
863
- app: XCUIApplication,
864
- target: TextEntryTarget,
865
- timeout: TimeInterval = TextEntryTiming.readinessTimeout
866
- ) -> XCUIElement? {
867
- #if os(iOS)
868
- var latest = resolveTextEntryElement(app: app, target: target)
869
- let deadline = Date().addingTimeInterval(timeout)
870
- let hardwareKeyboardFallback = Date().addingTimeInterval(
871
- min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
872
- )
873
- var sawSoftwareKeyboard = false
874
- while Date() < deadline {
875
- if let focused = focusedTextInput(app: app) {
876
- latest = focused
877
- if isKeyboardVisible(app: app) {
878
- return focused
879
- }
880
- }
881
- sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
882
- if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
883
- return latest
884
- }
885
- sleepFor(TextEntryTiming.pollInterval)
886
- }
887
- return focusedTextInput(app: app) ?? latest
888
- #else
889
- return resolveTextEntryElement(app: app, target: target)
890
- #endif
891
- }
892
-
893
- func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {
894
- #if os(iOS)
895
- switch element.elementType {
896
- case .textField, .secureTextField, .searchField, .textView:
897
- if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil {
898
- return
899
- }
900
- let frame = element.frame
901
- if !frame.isEmpty {
902
- _ = tapAt(app: app, x: frame.midX, y: frame.midY)
903
- _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout)
904
- }
905
- default:
906
- return
907
- }
908
- #endif
909
- }
910
-
911
- private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
912
- let deadline = Date().addingTimeInterval(timeout)
913
- while Date() < deadline {
914
- if let focused = focusedTextInput(app: app) {
915
- return focused
916
- }
917
- sleepFor(TextEntryTiming.pollInterval)
918
- }
919
- return focusedTextInput(app: app)
920
- }
921
-
922
- private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
923
- guard let element else {
924
- return nil
925
- }
926
- let frame = element.frame
927
- guard !frame.isEmpty else {
928
- return nil
929
- }
930
- return CGPoint(x: frame.midX, y: frame.midY)
931
- }
932
-
933
339
  func isKeyboardVisible(app: XCUIApplication) -> Bool {
934
340
  return visibleKeyboardFrame(app: app) != nil
935
341
  }
936
342
 
937
- private func keyboardElementExists(app: XCUIApplication) -> Bool {
938
- #if os(iOS)
939
- var exists = false
940
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
941
- exists = app.keyboards.firstMatch.exists
942
- })
943
- if let exceptionMessage {
944
- NSLog(
945
- "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
946
- exceptionMessage
947
- )
948
- return false
949
- }
950
- return exists
951
- #else
952
- return false
953
- #endif
954
- }
955
-
956
343
  func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
957
344
  let wasVisible = isKeyboardVisible(app: app)
958
345
  guard wasVisible else {
@@ -978,6 +365,14 @@ extension RunnerTests {
978
365
  return (wasVisible: true, dismissed: !visible, visible: visible)
979
366
  }
980
367
 
368
+ if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) {
369
+ sleepFor(0.2)
370
+ let visible = isKeyboardVisible(app: app)
371
+ if !visible {
372
+ return (wasVisible: true, dismissed: true, visible: false)
373
+ }
374
+ }
375
+
981
376
  return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
982
377
  #endif
983
378
  }
@@ -1098,7 +493,10 @@ extension RunnerTests {
1098
493
  #endif
1099
494
  }
1100
495
 
1101
- private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
496
+ private func tapKeyboardReturnControl(
497
+ app: XCUIApplication,
498
+ allowCoordinateFallback: Bool = false
499
+ ) -> Bool {
1102
500
  #if os(iOS)
1103
501
  for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
1104
502
  let candidates = [
@@ -1109,6 +507,21 @@ extension RunnerTests {
1109
507
  hittable.tap()
1110
508
  return true
1111
509
  }
510
+ if allowCoordinateFallback,
511
+ let keyboardFrame = visibleKeyboardFrame(app: app),
512
+ let framed = candidates.first(where: {
513
+ guard $0.exists else { return false }
514
+ let frame = $0.frame
515
+ return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
516
+ }) {
517
+ let frame = framed.frame
518
+ switch tapAt(app: app, x: frame.midX, y: frame.midY) {
519
+ case .performed:
520
+ return true
521
+ case .unsupported:
522
+ return false
523
+ }
524
+ }
1112
525
  }
1113
526
  #endif
1114
527
  return false
@@ -1122,80 +535,6 @@ extension RunnerTests {
1122
535
  return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
1123
536
  }
1124
537
 
1125
- private func moveCaretToEnd(element: XCUIElement) {
1126
- #if os(tvOS)
1127
- return
1128
- #else
1129
- let frame = element.frame
1130
- guard !frame.isEmpty else {
1131
- element.tap()
1132
- return
1133
- }
1134
- let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
1135
- let target = origin.withOffset(
1136
- CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
1137
- )
1138
- target.tap()
1139
- #endif
1140
- }
1141
-
1142
- private func estimatedDeleteCount(for element: XCUIElement) -> Int {
1143
- let valueText = normalizedElementText(element.value)
1144
- let base = valueText.isEmpty ? 24 : (valueText.count + 8)
1145
- return max(24, min(120, base))
1146
- }
1147
-
1148
- private func normalizedElementText(_ value: Any?) -> String {
1149
- String(describing: value ?? "")
1150
- .trimmingCharacters(in: .whitespacesAndNewlines)
1151
- }
1152
-
1153
- private func editableTextValue(
1154
- for element: XCUIElement?,
1155
- treatingPlaceholderAsEmpty: Bool = false
1156
- ) -> String? {
1157
- guard let element else {
1158
- return nil
1159
- }
1160
- switch element.elementType {
1161
- case .textField, .searchField, .textView:
1162
- let value = String(describing: element.value ?? "")
1163
- if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
1164
- return ""
1165
- }
1166
- return value
1167
- case .secureTextField:
1168
- return nil
1169
- default:
1170
- return nil
1171
- }
1172
- }
1173
-
1174
- private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
1175
- let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
1176
- guard !normalizedValue.isEmpty else {
1177
- return false
1178
- }
1179
- let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1180
- if !placeholder.isEmpty && normalizedValue == placeholder {
1181
- return true
1182
- }
1183
- if isGenericTextInputLabel(normalizedValue) {
1184
- return true
1185
- }
1186
- let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1187
- return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
1188
- }
1189
-
1190
- private func isGenericTextInputLabel(_ value: String) -> Bool {
1191
- switch value {
1192
- case "Text input field":
1193
- return true
1194
- default:
1195
- return false
1196
- }
1197
- }
1198
-
1199
538
  private func readableText(for element: XCUIElement) -> String? {
1200
539
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1201
540
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1510,11 +849,69 @@ extension RunnerTests {
1510
849
  }
1511
850
 
1512
851
  func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1513
- return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
852
+ #if os(iOS)
853
+ // A coordinate tap+drag is a single-finger gesture: React Native reads it as a pan
854
+ // and the pinch scale never changes (#629). Drive the two-finger XCTest synthesis
855
+ // path (the same one transformGesture uses) with zero translation/rotation so RN's
856
+ // pinch recognizer actually fires.
857
+ let frame = interactionRoot(app: app).frame
858
+ let centerX = x ?? Double(frame.midX)
859
+ let centerY = y ?? Double(frame.midY)
860
+ return transformGesture(
861
+ app: app,
862
+ x: centerX,
863
+ y: centerY,
864
+ dx: 0,
865
+ dy: 0,
866
+ scale: scale,
867
+ degrees: 0,
868
+ durationMs: 300
869
+ )
870
+ #elseif os(tvOS)
871
+ return .unsupported(
872
+ message: "pinch is not supported on tvOS",
873
+ hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
874
+ )
875
+ #else
876
+ return .unsupported(
877
+ message: "pinch is not supported on macOS",
878
+ hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
879
+ )
880
+ #endif
1514
881
  }
1515
882
 
1516
883
  func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
1517
- return performCoordinateRotateGesture(app: app, degrees: degrees, x: x, y: y, velocity: velocity)
884
+ #if os(iOS)
885
+ // Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
886
+ // with zero translation/scale so React Native's rotation recognizer actually fires. The native
887
+ // XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
888
+ // handler does not read reliably — the same class of problem #629/#634 fixed for pinch.
889
+ // velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
890
+ // keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
891
+ let frame = interactionRoot(app: app).frame
892
+ let centerX = x ?? Double(frame.midX)
893
+ let centerY = y ?? Double(frame.midY)
894
+ return transformGesture(
895
+ app: app,
896
+ x: centerX,
897
+ y: centerY,
898
+ dx: 0,
899
+ dy: 0,
900
+ scale: 1,
901
+ degrees: degrees,
902
+ durationMs: 300
903
+ )
904
+ #elseif os(tvOS)
905
+ return .unsupported(
906
+ message: "rotate-gesture is not supported on tvOS",
907
+ hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
908
+ )
909
+ #else
910
+ return .unsupported(
911
+ message: "rotate-gesture is not supported on macOS",
912
+ hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
913
+ )
914
+ #endif
1518
915
  }
1519
916
 
1520
917
  func transformGesture(
@@ -1540,13 +937,22 @@ extension RunnerTests {
1540
937
  radius: transformGestureRadius(frame: target.frame, scale: scale),
1541
938
  durationMs: durationMs
1542
939
  ) {
1543
- return .unsupported(message)
940
+ return .unsupported(
941
+ message: message,
942
+ hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
943
+ )
1544
944
  }
1545
945
  return .performed
1546
946
  #elseif os(tvOS)
1547
- return .unsupported("transformGesture is not supported on tvOS")
947
+ return .unsupported(
948
+ message: "transformGesture is not supported on tvOS",
949
+ hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
950
+ )
1548
951
  #else
1549
- return .unsupported("transformGesture is not supported on macOS")
952
+ return .unsupported(
953
+ message: "transformGesture is not supported on macOS",
954
+ hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
955
+ )
1550
956
  #endif
1551
957
  }
1552
958
 
@@ -1558,57 +964,6 @@ extension RunnerTests {
1558
964
  return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
1559
965
  }
1560
966
 
1561
- private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1562
- #if os(tvOS)
1563
- return .unsupported("pinch is not supported on tvOS")
1564
- #else
1565
- let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
1566
-
1567
- // Use double-tap + drag gesture for reliable map zoom
1568
- // Zoom in (scale > 1): tap then drag UP
1569
- // Zoom out (scale < 1): tap then drag DOWN
1570
-
1571
- // Determine center point (use provided x/y or screen center)
1572
- let centerX = x.map { $0 / target.frame.width } ?? 0.5
1573
- let centerY = y.map { $0 / target.frame.height } ?? 0.5
1574
- let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))
1575
-
1576
- // Calculate drag distance based on scale (clamped to reasonable range)
1577
- // Larger scale = more drag distance
1578
- let dragAmount: CGFloat
1579
- if scale > 1.0 {
1580
- // Zoom in: drag up (negative Y direction in normalized coords)
1581
- dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
1582
- } else {
1583
- // Zoom out: drag down (positive Y direction)
1584
- dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
1585
- }
1586
-
1587
- let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
1588
- let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))
1589
-
1590
- // Tap first (first tap of double-tap)
1591
- center.tap()
1592
-
1593
- // Immediately press and drag (second tap + drag)
1594
- center.press(forDuration: 0.05, thenDragTo: endPoint)
1595
- return .performed
1596
- #endif
1597
- }
1598
-
1599
- private func performCoordinateRotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
1600
- #if os(iOS)
1601
- let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
1602
- let radians = CGFloat(degrees * .pi / 180.0)
1603
- target.rotate(radians, withVelocity: CGFloat(velocity))
1604
- return .performed
1605
- #elseif os(tvOS)
1606
- return .unsupported("rotate-gesture is not supported on tvOS")
1607
- #else
1608
- return .unsupported("rotate-gesture is not supported on macOS")
1609
- #endif
1610
- }
1611
-
1612
967
  private func interactionRoot(app: XCUIApplication) -> XCUIElement {
1613
968
  let windows = app.windows.allElementsBoundByIndex
1614
969
  if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
@@ -1619,7 +974,10 @@ extension RunnerTests {
1619
974
 
1620
975
  private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1621
976
  #if os(tvOS)
1622
- return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
977
+ return .unsupported(
978
+ message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
979
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
980
+ )
1623
981
  #else
1624
982
  interactionCoordinate(app: app, x: x, y: y).tap()
1625
983
  return .performed
@@ -1628,7 +986,10 @@ extension RunnerTests {
1628
986
 
1629
987
  private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1630
988
  #if os(tvOS)
1631
- return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
989
+ return .unsupported(
990
+ message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
991
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
992
+ )
1632
993
  #else
1633
994
  interactionCoordinate(app: app, x: x, y: y).doubleTap()
1634
995
  return .performed
@@ -1637,7 +998,10 @@ extension RunnerTests {
1637
998
 
1638
999
  private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
1639
1000
  #if os(tvOS)
1640
- return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
1001
+ return .unsupported(
1002
+ message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
1003
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
1004
+ )
1641
1005
  #else
1642
1006
  interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
1643
1007
  return .performed
@@ -1653,7 +1017,10 @@ extension RunnerTests {
1653
1017
  holdDuration: TimeInterval
1654
1018
  ) -> RunnerInteractionOutcome {
1655
1019
  #if os(tvOS)
1656
- return .unsupported("coordinate drag is not supported on tvOS")
1020
+ return .unsupported(
1021
+ message: "coordinate drag is not supported on tvOS",
1022
+ hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
1023
+ )
1657
1024
  #else
1658
1025
  let start = interactionCoordinate(app: app, x: x, y: y)
1659
1026
  let end = interactionCoordinate(app: app, x: x2, y: y2)