agent-device 0.16.8 → 0.16.10

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 (27) hide show
  1. package/README.md +1 -0
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.8.apk → agent-device-android-multitouch-helper-0.16.10.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.10.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.8.manifest.json → agent-device-android-multitouch-helper-0.16.10.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.8.apk → agent-device-android-snapshot-helper-0.16.10.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.10.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.8.manifest.json → agent-device-android-snapshot-helper-0.16.10.manifest.json} +6 -6
  8. package/dist/src/2415.js +19 -19
  9. package/dist/src/8114.js +3 -3
  10. package/dist/src/apps.js +2 -2
  11. package/dist/src/generic.js +4 -3
  12. package/dist/src/input-actions.js +1 -1
  13. package/dist/src/session.js +2 -2
  14. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +197 -232
  15. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +282 -0
  16. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +29 -0
  17. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +8 -771
  18. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +30 -0
  19. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -20
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +10 -50
  21. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +723 -0
  22. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +64 -22
  23. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +7 -4
  24. package/package.json +1 -1
  25. package/server.json +2 -2
  26. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.8.apk.sha256 +0 -1
  27. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.8.apk.sha256 +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 {
@@ -322,40 +280,18 @@ extension RunnerTests {
322
280
  return nil
323
281
  }
324
282
 
325
- func clearTextInput(_ element: XCUIElement) {
326
- // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
327
- // field is empty. Why skip: the edge-tap computes a point from the element frame, which can
328
- // be stale after the field repositions on focus (e.g. the Settings search bar jumps
329
- // bottom->top and reveals a "Suggestions" list) — tapping there navigates away instead of
330
- // clearing; and replacing into an already-empty field is a no-op anyway.
331
- // editableTextValue returns nil for secure (and unknown) fields, where we CANNOT confirm
332
- // emptiness — those must still be cleared, or replace would concatenate stale + new text.
333
- // So distinguish nil (clear) from "" (skip).
334
- if let existing = editableTextValue(for: element, treatingPlaceholderAsEmpty: true),
335
- existing.isEmpty {
336
- return
337
- }
338
- #if !os(tvOS)
339
- moveCaretToEnd(element: element)
340
- #endif
341
- let count = estimatedDeleteCount(for: element)
342
- let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
343
- element.typeText(deletes)
344
- }
345
-
346
283
  func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
347
284
  return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
348
285
  }
349
286
 
350
287
  private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
351
- var candidates: [XCUIElement] = []
352
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
288
+ safely("TEXT_INPUT_AT_POINT", []) {
353
289
  // Query the text-input element types directly instead of enumerating the entire tree
354
290
  // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
355
291
  // slower — it dominated fill latency because resolveTextEntryElement re-runs this on
356
292
  // each verify/repair poll once the focused field reference goes stale).
357
293
  // Prefer the smallest matching field so nested editable controls win over large containers.
358
- candidates = [
294
+ [
359
295
  app.textFields,
360
296
  app.secureTextFields,
361
297
  app.searchFields,
@@ -381,15 +317,7 @@ extension RunnerTests {
381
317
  }
382
318
  return left.elementType.rawValue < right.elementType.rawValue
383
319
  }
384
- })
385
- if let exceptionMessage {
386
- NSLog(
387
- "AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
388
- exceptionMessage
389
- )
390
- return []
391
320
  }
392
- return candidates
393
321
  }
394
322
 
395
323
  private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
@@ -399,610 +327,10 @@ extension RunnerTests {
399
327
  && point.y <= frame.maxY + tolerance
400
328
  }
401
329
 
402
- func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
403
- #if os(iOS)
404
- // iOS focus predicates can return stale or misleading text-input matches
405
- // under XCUITest, so text entry readiness is driven by tap/keyboard state.
406
- return nil
407
- #else
408
- var focused: XCUIElement?
409
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
410
- let candidates = app
411
- .descendants(matching: .any)
412
- .matching(NSPredicate(format: "hasKeyboardFocus == 1"))
413
- .allElementsBoundByIndex
414
- for candidate in candidates where candidate.exists {
415
- switch candidate.elementType {
416
- case .textField, .secureTextField, .searchField, .textView:
417
- focused = candidate
418
- return
419
- default:
420
- continue
421
- }
422
- }
423
- })
424
- if let exceptionMessage {
425
- NSLog(
426
- "AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
427
- exceptionMessage
428
- )
429
- return nil
430
- }
431
- return focused
432
- #endif
433
- }
434
-
435
- func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
436
- #if os(tvOS)
437
- return target
438
- #else
439
- let latest = target
440
- let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
441
- let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
442
- while Date() < deadline {
443
- if let focused = focusedTextInput(app: app) {
444
- return focused
445
- }
446
- // focusedTextInput is intentionally nil on iOS; treat the keyboard transitioning to
447
- // visible after our tap as the focus-moved signal. Don't fast-path when it was already up.
448
- if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
449
- return latest
450
- }
451
- sleepFor(TextEntryTiming.pollInterval)
452
- }
453
- return latest
454
- #endif
455
- }
456
-
457
- func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
458
- guard let x, let y else {
459
- let focused = waitForTextEntryReadiness(
460
- app: app,
461
- target: TextEntryTarget(
462
- element: focusedTextInput(app: app),
463
- refreshPoint: nil,
464
- prefersFocusedElement: true
465
- )
466
- )
467
- return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
468
- }
469
-
470
- let target = textInputAt(app: app, x: x, y: y)
471
- let requestedPoint = CGPoint(x: x, y: y)
472
- if let target {
473
- let frame = target.frame
474
- if !frame.isEmpty {
475
- _ = tapAt(app: app, x: frame.midX, y: frame.midY)
476
- } else {
477
- _ = tapAt(app: app, x: x, y: y)
478
- }
479
- } else {
480
- _ = tapAt(app: app, x: x, y: y)
481
- }
482
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
483
- let element = waitForTextEntryReadiness(
484
- app: app,
485
- target: TextEntryTarget(
486
- element: stabilized ?? target,
487
- refreshPoint: requestedPoint,
488
- prefersFocusedElement: false
489
- )
490
- ) ?? stabilized ?? target
491
- return TextEntryTarget(
492
- element: element,
493
- refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
494
- prefersFocusedElement: false
495
- )
496
- }
497
-
498
- func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
499
- let point = textEntryRefreshPoint(for: element)
500
- if let point {
501
- _ = tapAt(app: app, x: point.x, y: point.y)
502
- }
503
- let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
504
- let resolved = waitForTextEntryReadiness(
505
- app: app,
506
- target: TextEntryTarget(
507
- element: stabilized ?? element,
508
- refreshPoint: point,
509
- prefersFocusedElement: false
510
- )
511
- ) ?? stabilized ?? element
512
- return TextEntryTarget(
513
- element: resolved,
514
- refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
515
- prefersFocusedElement: false
516
- )
517
- }
518
-
519
- func isTextEntryElement(_ element: XCUIElement) -> Bool {
520
- switch element.elementType {
521
- case .textField, .secureTextField, .searchField, .textView:
522
- return true
523
- default:
524
- return false
525
- }
526
- }
527
-
528
- func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
529
- switch command.textEntryMode {
530
- case "append":
531
- return .append
532
- case "replace":
533
- return .replacement
534
- default:
535
- return command.clearFirst == true ? .replacement : .none
536
- }
537
- }
538
-
539
- func typeTextReliably(
540
- app: XCUIApplication,
541
- target: TextEntryTarget,
542
- text: String,
543
- delaySeconds: Double,
544
- repairMode: TextTypingRepairMode = .none
545
- ) -> TextEntryResult {
546
- guard !text.isEmpty else {
547
- return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
548
- }
549
- var activeTarget = target
550
- let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
551
- activeTarget = activeTarget.withElement(initialTarget)
552
- let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
553
- let initialText = repairMode == .append ? currentText : nil
554
- let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
555
-
556
- if repairMode == .replacement {
557
- guard let replacementTarget = initialTarget else {
558
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
559
- }
560
- if currentText == nil || currentText?.isEmpty == false {
561
- clearTextInput(replacementTarget)
562
- activeTarget = activeTarget.withElement(replacementTarget)
563
- }
564
- }
565
-
566
- func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
567
- if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
568
- app.typeText(value)
569
- return currentTarget
570
- } else {
571
- app.typeText(value)
572
- return resolveTextEntryElement(app: app, target: activeTarget)
573
- }
574
- }
575
-
576
- func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
577
- guard let expectedValue else {
578
- sleepFor(TextEntryTiming.pollInterval)
579
- return
580
- }
581
- let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
582
- while Date() < deadline {
583
- if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
584
- return
585
- }
586
- sleepFor(TextEntryTiming.pollInterval)
587
- }
588
- }
589
-
590
- let characters = Array(text)
591
- if delaySeconds > 0 && characters.count > 1 {
592
- var typedTarget: XCUIElement?
593
- for (index, character) in characters.enumerated() {
594
- typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
595
- if index + 1 < characters.count {
596
- sleepFor(delaySeconds)
597
- }
598
- }
599
- if repairMode == .none {
600
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
601
- }
602
- let repairResult = repairTextEntryIfNeeded(
603
- app: app,
604
- target: activeTarget.withElement(typedTarget),
605
- expectedText: expectedText,
606
- repairMode: repairMode
607
- )
608
- return verifyTextEntry(
609
- app: app,
610
- target: activeTarget.withElement(typedTarget),
611
- expectedText: expectedText,
612
- repaired: repairResult.repaired
613
- )
614
- }
615
-
616
- let typedTarget: XCUIElement?
617
- if repairMode != .none && characters.count > 1 {
618
- let firstCharacter = String(characters[0])
619
- var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
620
- activeTarget = activeTarget.withElement(firstTypedTarget)
621
- let warmupExpectedText = expectedTextEntryValue(
622
- typedText: firstCharacter,
623
- mode: repairMode,
624
- initialText: initialText
625
- )
626
- waitForWarmupValue(warmupExpectedText, target: activeTarget)
627
- let remainingText = String(characters.dropFirst())
628
- firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
629
- typedTarget = firstTypedTarget
630
- } else {
631
- typedTarget = typeIntoCurrentTarget(text)
632
- }
633
- if repairMode == .none {
634
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
635
- }
636
- let repairResult = repairTextEntryIfNeeded(
637
- app: app,
638
- target: activeTarget.withElement(typedTarget),
639
- expectedText: expectedText,
640
- repairMode: repairMode
641
- )
642
- return verifyTextEntry(
643
- app: app,
644
- target: activeTarget.withElement(typedTarget),
645
- expectedText: expectedText,
646
- repaired: repairResult.repaired
647
- )
648
- }
649
-
650
- private func repairTextEntryIfNeeded(
651
- app: XCUIApplication,
652
- target: TextEntryTarget,
653
- expectedText: String?,
654
- repairMode: TextTypingRepairMode
655
- ) -> TextEntryResult {
656
- #if os(iOS)
657
- guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
658
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
659
- }
660
- guard let expectedText else {
661
- let observedText = editableTextValue(for: targetElement)
662
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
663
- }
664
- guard shouldRepairTextEntry(
665
- app: app,
666
- target: target,
667
- expectedText: expectedText,
668
- repairMode: repairMode
669
- ) else {
670
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
671
- }
672
-
673
- guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
674
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
675
- }
676
- let observedText = editableTextValue(for: repairTarget) ?? ""
677
- NSLog(
678
- "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
679
- expectedText.count,
680
- observedText.count
681
- )
682
- clearTextInput(repairTarget)
683
- app.typeText(expectedText)
684
- return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
685
- #else
686
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
687
- #endif
688
- }
689
-
690
- private func verifyTextEntry(
691
- app: XCUIApplication,
692
- target: TextEntryTarget,
693
- expectedText: String?,
694
- repaired: Bool
695
- ) -> TextEntryResult {
696
- let targetElement = resolveTextEntryElement(app: app, target: target)
697
- guard let expectedText else {
698
- return TextEntryResult(
699
- verified: nil,
700
- repaired: repaired,
701
- expectedText: nil,
702
- observedText: editableTextValue(for: targetElement)
703
- )
704
- }
705
- guard let observedText = editableTextValue(for: targetElement) else {
706
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
707
- }
708
- guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
709
- return TextEntryResult(
710
- verified: false,
711
- repaired: repaired,
712
- expectedText: expectedText,
713
- observedText: observedText
714
- )
715
- }
716
- let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
717
- var latestObservedText = observedText
718
- while Date() < stableDeadline {
719
- sleepFor(TextEntryTiming.pollInterval)
720
- guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
721
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
722
- }
723
- latestObservedText = nextObservedText
724
- guard textEntryValueMatchesExpected(
725
- resolveTextEntryElement(app: app, target: target),
726
- observedText: nextObservedText,
727
- expectedText: expectedText
728
- ) else {
729
- return TextEntryResult(
730
- verified: false,
731
- repaired: repaired,
732
- expectedText: expectedText,
733
- observedText: nextObservedText
734
- )
735
- }
736
- }
737
- return TextEntryResult(
738
- verified: true,
739
- repaired: repaired,
740
- expectedText: expectedText,
741
- observedText: latestObservedText
742
- )
743
- }
744
-
745
- private func textEntryValueMatchesExpected(
746
- _ element: XCUIElement?,
747
- observedText: String,
748
- expectedText: String
749
- ) -> Bool {
750
- if observedText == expectedText {
751
- return true
752
- }
753
- guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
754
- return false
755
- }
756
- var submittedText = expectedText
757
- while hasTextEntrySubmitSuffix(submittedText) {
758
- submittedText.removeLast()
759
- }
760
- return observedText == submittedText
761
- }
762
-
763
- private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
764
- text.hasSuffix("\n") || text.hasSuffix("\r")
765
- }
766
-
767
- private func expectedTextEntryValue(
768
- typedText: String,
769
- mode: TextTypingRepairMode,
770
- initialText: String?
771
- ) -> String? {
772
- switch mode {
773
- case .none:
774
- return nil
775
- case .append:
776
- guard let initialText else {
777
- return nil
778
- }
779
- return initialText + typedText
780
- case .replacement:
781
- return typedText
782
- }
783
- }
784
-
785
- private func shouldRepairTextEntry(
786
- app: XCUIApplication,
787
- target: TextEntryTarget,
788
- expectedText: String,
789
- repairMode: TextTypingRepairMode
790
- ) -> Bool {
791
- #if os(iOS)
792
- var latestObservedText: String?
793
- let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
794
- repeat {
795
- guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
796
- return false
797
- }
798
- if textEntryValueMatchesExpected(
799
- resolveTextEntryElement(app: app, target: target),
800
- observedText: observedText,
801
- expectedText: expectedText
802
- ) {
803
- return false
804
- }
805
- latestObservedText = observedText
806
- if !isRepairableTextEntryMismatch(
807
- observedText: observedText,
808
- expectedText: expectedText,
809
- repairMode: repairMode
810
- ) {
811
- return false
812
- }
813
- sleepFor(TextEntryTiming.pollInterval)
814
- } while Date() < deadline
815
-
816
- guard let latestObservedText else {
817
- return false
818
- }
819
- guard !textEntryValueMatchesExpected(
820
- resolveTextEntryElement(app: app, target: target),
821
- observedText: latestObservedText,
822
- expectedText: expectedText
823
- ) else {
824
- return false
825
- }
826
- return isRepairableTextEntryMismatch(
827
- observedText: latestObservedText,
828
- expectedText: expectedText,
829
- repairMode: repairMode
830
- )
831
- #else
832
- return false
833
- #endif
834
- }
835
-
836
- private func isRepairableTextEntryMismatch(
837
- observedText: String,
838
- expectedText: String,
839
- repairMode: TextTypingRepairMode
840
- ) -> Bool {
841
- guard observedText != expectedText else {
842
- return false
843
- }
844
- if repairMode == .replacement {
845
- return true
846
- }
847
- return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
848
- observedText: observedText,
849
- expectedText: expectedText
850
- )
851
- }
852
-
853
- private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
854
- guard observedText.count < expectedText.count else {
855
- return false
856
- }
857
- let missingCharacterCount = expectedText.count - observedText.count
858
- guard missingCharacterCount <= max(2, expectedText.count / 4) else {
859
- return false
860
- }
861
- var expectedIndex = expectedText.startIndex
862
- for character in observedText {
863
- guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
864
- return false
865
- }
866
- expectedIndex = expectedText.index(after: matchIndex)
867
- }
868
- return true
869
- }
870
-
871
- private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
872
- if target.prefersFocusedElement {
873
- if let focused = focusedTextInput(app: app) {
874
- return focused
875
- }
876
- if let element = target.element, element.exists {
877
- return element
878
- }
879
- } else {
880
- if let element = target.element, element.exists {
881
- return element
882
- }
883
- }
884
- if let refreshPoint = target.refreshPoint,
885
- let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
886
- return refreshed
887
- }
888
- if let focused = focusedTextInput(app: app) {
889
- return focused
890
- }
891
- return nil
892
- }
893
-
894
- private func waitForTextEntryReadiness(
895
- app: XCUIApplication,
896
- target: TextEntryTarget,
897
- timeout: TimeInterval = TextEntryTiming.readinessTimeout
898
- ) -> XCUIElement? {
899
- #if os(iOS)
900
- var latest = resolveTextEntryElement(app: app, target: target)
901
- let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
902
- let deadline = Date().addingTimeInterval(timeout)
903
- let hardwareKeyboardFallback = Date().addingTimeInterval(
904
- min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
905
- )
906
- var sawSoftwareKeyboard = false
907
- while Date() < deadline {
908
- if let focused = focusedTextInput(app: app) {
909
- latest = focused
910
- if isKeyboardVisible(app: app) {
911
- return focused
912
- }
913
- }
914
- // Fast-path on a keyboard hidden->visible transition: our tapped field gained focus, so
915
- // return immediately instead of burning the full readinessTimeout (warmup-first-char echo
916
- // + post-type verify/repair remain as drop safety nets). When the keyboard was ALREADY up
917
- // (back-to-back fills), this isn't a focus signal — fall through to the settle/timeout so
918
- // text isn't sent to the previously-focused field.
919
- if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
920
- return latest
921
- }
922
- sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
923
- if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
924
- return latest
925
- }
926
- sleepFor(TextEntryTiming.pollInterval)
927
- }
928
- return focusedTextInput(app: app) ?? latest
929
- #else
930
- return resolveTextEntryElement(app: app, target: target)
931
- #endif
932
- }
933
-
934
- func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {
935
- #if os(iOS)
936
- switch element.elementType {
937
- case .textField, .secureTextField, .searchField, .textView:
938
- if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil {
939
- return
940
- }
941
- let frame = element.frame
942
- if !frame.isEmpty {
943
- _ = tapAt(app: app, x: frame.midX, y: frame.midY)
944
- _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout)
945
- }
946
- default:
947
- return
948
- }
949
- #endif
950
- }
951
-
952
- private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
953
- let deadline = Date().addingTimeInterval(timeout)
954
- while Date() < deadline {
955
- if let focused = focusedTextInput(app: app) {
956
- return focused
957
- }
958
- sleepFor(TextEntryTiming.pollInterval)
959
- }
960
- return focusedTextInput(app: app)
961
- }
962
-
963
- private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
964
- guard let element else {
965
- return nil
966
- }
967
- let frame = element.frame
968
- guard !frame.isEmpty else {
969
- return nil
970
- }
971
- return CGPoint(x: frame.midX, y: frame.midY)
972
- }
973
-
974
330
  func isKeyboardVisible(app: XCUIApplication) -> Bool {
975
331
  return visibleKeyboardFrame(app: app) != nil
976
332
  }
977
333
 
978
- /// A focus-moved signal for iOS text entry, where `focusedTextInput` is intentionally nil.
979
- /// The software keyboard TRANSITIONING from hidden (at entry) to visible means the field we
980
- /// just tapped gained first-responder. If the keyboard was ALREADY up (e.g. back-to-back
981
- /// fills into different fields), its visibility is not evidence focus moved to the new field,
982
- /// so callers must keep waiting rather than typing into the previously-focused field.
983
- private func keyboardBecameVisible(app: XCUIApplication, wasVisibleAtEntry: Bool) -> Bool {
984
- return !wasVisibleAtEntry && isKeyboardVisible(app: app)
985
- }
986
-
987
- private func keyboardElementExists(app: XCUIApplication) -> Bool {
988
- #if os(iOS)
989
- var exists = false
990
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
991
- exists = app.keyboards.firstMatch.exists
992
- })
993
- if let exceptionMessage {
994
- NSLog(
995
- "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
996
- exceptionMessage
997
- )
998
- return false
999
- }
1000
- return exists
1001
- #else
1002
- return false
1003
- #endif
1004
- }
1005
-
1006
334
  func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
1007
335
  let wasVisible = isKeyboardVisible(app: app)
1008
336
  guard wasVisible else {
@@ -1094,9 +422,8 @@ extension RunnerTests {
1094
422
 
1095
423
  private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
1096
424
  #if os(iOS)
1097
- var matches: [XCUIElement] = []
1098
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1099
- matches = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
425
+ let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
426
+ app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
1100
427
  guard element.exists else { return false }
1101
428
  switch element.elementType {
1102
429
  case .textField, .secureTextField, .searchField, .textView:
@@ -1105,13 +432,6 @@ extension RunnerTests {
1105
432
  return false
1106
433
  }
1107
434
  }
1108
- })
1109
- if let exceptionMessage {
1110
- NSLog(
1111
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TEXT_ENTRY_QUERY_IGNORED_EXCEPTION=%@",
1112
- exceptionMessage
1113
- )
1114
- return nil
1115
435
  }
1116
436
  return matches.count == 1 ? matches[0] : nil
1117
437
  #else
@@ -1198,80 +518,6 @@ extension RunnerTests {
1198
518
  return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
1199
519
  }
1200
520
 
1201
- private func moveCaretToEnd(element: XCUIElement) {
1202
- #if os(tvOS)
1203
- return
1204
- #else
1205
- let frame = element.frame
1206
- guard !frame.isEmpty else {
1207
- element.tap()
1208
- return
1209
- }
1210
- let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
1211
- let target = origin.withOffset(
1212
- CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
1213
- )
1214
- target.tap()
1215
- #endif
1216
- }
1217
-
1218
- private func estimatedDeleteCount(for element: XCUIElement) -> Int {
1219
- let valueText = normalizedElementText(element.value)
1220
- let base = valueText.isEmpty ? 24 : (valueText.count + 8)
1221
- return max(24, min(120, base))
1222
- }
1223
-
1224
- private func normalizedElementText(_ value: Any?) -> String {
1225
- String(describing: value ?? "")
1226
- .trimmingCharacters(in: .whitespacesAndNewlines)
1227
- }
1228
-
1229
- private func editableTextValue(
1230
- for element: XCUIElement?,
1231
- treatingPlaceholderAsEmpty: Bool = false
1232
- ) -> String? {
1233
- guard let element else {
1234
- return nil
1235
- }
1236
- switch element.elementType {
1237
- case .textField, .searchField, .textView:
1238
- let value = String(describing: element.value ?? "")
1239
- if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
1240
- return ""
1241
- }
1242
- return value
1243
- case .secureTextField:
1244
- return nil
1245
- default:
1246
- return nil
1247
- }
1248
- }
1249
-
1250
- private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
1251
- let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
1252
- guard !normalizedValue.isEmpty else {
1253
- return false
1254
- }
1255
- let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1256
- if !placeholder.isEmpty && normalizedValue == placeholder {
1257
- return true
1258
- }
1259
- if isGenericTextInputLabel(normalizedValue) {
1260
- return true
1261
- }
1262
- let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1263
- return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
1264
- }
1265
-
1266
- private func isGenericTextInputLabel(_ value: String) -> Bool {
1267
- switch value {
1268
- case "Text input field":
1269
- return true
1270
- default:
1271
- return false
1272
- }
1273
- }
1274
-
1275
521
  private func readableText(for element: XCUIElement) -> String? {
1276
522
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1277
523
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1521,22 +767,13 @@ extension RunnerTests {
1521
767
 
1522
768
  private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
1523
769
  #if os(iOS)
1524
- var frame: CGRect?
1525
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
770
+ return safely("KEYBOARD_FRAME") {
1526
771
  let keyboard = app.keyboards.firstMatch
1527
- guard keyboard.exists else { return }
772
+ guard keyboard.exists else { return nil }
1528
773
  let keyboardFrame = keyboard.frame
1529
- guard !keyboardFrame.isEmpty else { return }
1530
- frame = keyboardFrame
1531
- })
1532
- if let exceptionMessage {
1533
- NSLog(
1534
- "AGENT_DEVICE_RUNNER_KEYBOARD_FRAME_IGNORED_EXCEPTION=%@",
1535
- exceptionMessage
1536
- )
1537
- return nil
774
+ guard !keyboardFrame.isEmpty else { return nil }
775
+ return keyboardFrame
1538
776
  }
1539
- return frame
1540
777
  #else
1541
778
  return nil
1542
779
  #endif