agent-device 0.14.9 → 0.15.0

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 (44) hide show
  1. package/README.md +7 -4
  2. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.9.apk → agent-device-android-snapshot-helper-0.15.0.apk} +0 -0
  3. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.0.apk.sha256 +1 -0
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.9.manifest.json → agent-device-android-snapshot-helper-0.15.0.manifest.json} +6 -6
  5. package/dist/src/1769.js +7 -0
  6. package/dist/src/2151.js +429 -0
  7. package/dist/src/221.js +4 -4
  8. package/dist/src/2842.js +1 -0
  9. package/dist/src/3572.js +1 -0
  10. package/dist/src/4057.js +1 -1
  11. package/dist/src/840.js +2 -0
  12. package/dist/src/9542.js +2 -2
  13. package/dist/src/9639.js +2 -2
  14. package/dist/src/android-adb.d.ts +38 -9
  15. package/dist/src/android-adb.js +1 -1
  16. package/dist/src/android-snapshot-helper.d.ts +23 -0
  17. package/dist/src/cli.js +60 -57
  18. package/dist/src/contracts.d.ts +1 -0
  19. package/dist/src/finders.d.ts +1 -0
  20. package/dist/src/index.d.ts +19 -22
  21. package/dist/src/internal/companion-tunnel.js +1 -1
  22. package/dist/src/internal/daemon.js +51 -23
  23. package/dist/src/remote-config.d.ts +17 -14
  24. package/dist/src/selectors.d.ts +2 -0
  25. package/dist/src/server.js +2 -20
  26. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
  27. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +128 -47
  28. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +734 -10
  29. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +93 -7
  30. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +5 -0
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +9 -0
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +1 -0
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
  35. package/package.json +25 -11
  36. package/server.json +3 -3
  37. package/skills/agent-device/SKILL.md +2 -7
  38. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.9.apk.sha256 +0 -1
  39. package/dist/src/180.js +0 -1
  40. package/dist/src/6108.js +0 -26
  41. package/dist/src/6642.js +0 -1
  42. package/dist/src/7462.js +0 -1
  43. package/dist/src/8809.js +0 -8
  44. package/dist/src/command-schema.js +0 -382
@@ -17,6 +17,60 @@ extension RunnerTests {
17
17
  let referenceHeight: Double
18
18
  }
19
19
 
20
+ struct DragPoints {
21
+ let x: Double
22
+ let y: Double
23
+ let x2: Double
24
+ let y2: Double
25
+ }
26
+
27
+ struct SelectorElementMatch {
28
+ let element: XCUIElement?
29
+ let isAmbiguous: Bool
30
+ }
31
+
32
+ enum TextTypingRepairMode {
33
+ case none
34
+ case append
35
+ case replacement
36
+ }
37
+
38
+ enum TextEntryTiming {
39
+ static let focusTimeout: TimeInterval = 0.4
40
+ static let repairReadinessTimeout: TimeInterval = 1.0
41
+ static let readinessTimeout: TimeInterval = 2.0
42
+ static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
43
+ static let pollInterval: TimeInterval = 0.02
44
+ static let warmupValueTimeout: TimeInterval = 0.4
45
+ static let verificationStabilityWindow: TimeInterval = 0.2
46
+ }
47
+
48
+ struct TextEntryResult {
49
+ let verified: Bool?
50
+ let repaired: Bool
51
+ let expectedText: String?
52
+ let observedText: String?
53
+ }
54
+
55
+ struct TextEntryTarget {
56
+ let element: XCUIElement?
57
+ let refreshPoint: CGPoint?
58
+ let prefersFocusedElement: Bool
59
+
60
+ func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
61
+ guard let nextElement else {
62
+ return self
63
+ }
64
+ let frame = nextElement.frame
65
+ let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
66
+ return TextEntryTarget(
67
+ element: nextElement,
68
+ refreshPoint: point,
69
+ prefersFocusedElement: prefersFocusedElement
70
+ )
71
+ }
72
+ }
73
+
20
74
  // MARK: - Navigation Gestures
21
75
 
22
76
  func tapInAppBackControl(app: XCUIApplication) -> Bool {
@@ -123,6 +177,78 @@ extension RunnerTests {
123
177
  return element.exists ? element : nil
124
178
  }
125
179
 
180
+ func findElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> SelectorElementMatch {
181
+ let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
182
+ guard !value.isEmpty else {
183
+ return SelectorElementMatch(element: nil, isAmbiguous: false)
184
+ }
185
+ let predicate: NSPredicate
186
+ switch selectorKey {
187
+ case "id":
188
+ predicate = NSPredicate(format: "identifier ==[c] %@", value)
189
+ case "label":
190
+ predicate = NSPredicate(format: "label ==[c] %@", value)
191
+ case "value":
192
+ predicate = NSPredicate(format: "value ==[c] %@", value)
193
+ case "text":
194
+ predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value)
195
+ default:
196
+ return SelectorElementMatch(element: nil, isAmbiguous: false)
197
+ }
198
+
199
+ var matchedElement: XCUIElement?
200
+ let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
201
+ for element in matches where element.exists {
202
+ guard element.isHittable else {
203
+ continue
204
+ }
205
+ guard matchedElement == nil else {
206
+ return SelectorElementMatch(element: nil, isAmbiguous: true)
207
+ }
208
+ matchedElement = element
209
+ }
210
+ return SelectorElementMatch(element: matchedElement, isAmbiguous: false)
211
+ }
212
+
213
+ func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
214
+ let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
215
+ if match.isAmbiguous {
216
+ return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
217
+ }
218
+ guard let element = match.element else {
219
+ return Response(ok: true, data: DataPayload(found: false, nodes: []))
220
+ }
221
+
222
+ let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
223
+ let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
224
+ let valueText = String(describing: element.value ?? "")
225
+ .trimmingCharacters(in: .whitespacesAndNewlines)
226
+ let node = SnapshotNode(
227
+ index: 0,
228
+ type: elementTypeName(element.elementType),
229
+ label: label.isEmpty ? nil : label,
230
+ identifier: identifier.isEmpty ? nil : identifier,
231
+ value: valueText.isEmpty ? nil : valueText,
232
+ rect: snapshotRect(from: element.frame),
233
+ enabled: element.isEnabled,
234
+ focused: nil,
235
+ selected: element.isSelected ? true : nil,
236
+ hittable: element.isHittable,
237
+ depth: 0,
238
+ parentIndex: nil,
239
+ hiddenContentAbove: nil,
240
+ hiddenContentBelow: nil
241
+ )
242
+ return Response(
243
+ ok: true,
244
+ data: DataPayload(
245
+ text: readableText(for: element),
246
+ found: true,
247
+ nodes: [node]
248
+ )
249
+ )
250
+ }
251
+
126
252
  func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
127
253
  let point = CGPoint(x: x, y: y)
128
254
  let candidates = app.descendants(matching: .any).allElementsBoundByIndex
@@ -234,9 +360,458 @@ extension RunnerTests {
234
360
  return focused
235
361
  }
236
362
 
363
+ func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
364
+ #if os(tvOS)
365
+ return target
366
+ #else
367
+ let latest = target
368
+ let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
369
+ while Date() < deadline {
370
+ if let focused = focusedTextInput(app: app) {
371
+ return focused
372
+ }
373
+ sleepFor(TextEntryTiming.pollInterval)
374
+ }
375
+ return latest
376
+ #endif
377
+ }
378
+
379
+ func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
380
+ guard let x, let y else {
381
+ let focused = waitForTextEntryReadiness(
382
+ app: app,
383
+ target: TextEntryTarget(
384
+ element: focusedTextInput(app: app),
385
+ refreshPoint: nil,
386
+ prefersFocusedElement: true
387
+ )
388
+ )
389
+ return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
390
+ }
391
+
392
+ let target = textInputAt(app: app, x: x, y: y)
393
+ let requestedPoint = CGPoint(x: x, y: y)
394
+ if let target {
395
+ let frame = target.frame
396
+ if !frame.isEmpty {
397
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
398
+ } else {
399
+ _ = tapAt(app: app, x: x, y: y)
400
+ }
401
+ } else {
402
+ _ = tapAt(app: app, x: x, y: y)
403
+ }
404
+ let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
405
+ let element = waitForTextEntryReadiness(
406
+ app: app,
407
+ target: TextEntryTarget(
408
+ element: stabilized ?? target,
409
+ refreshPoint: requestedPoint,
410
+ prefersFocusedElement: false
411
+ )
412
+ ) ?? stabilized ?? target
413
+ return TextEntryTarget(
414
+ element: element,
415
+ refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
416
+ prefersFocusedElement: false
417
+ )
418
+ }
419
+
420
+ func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
421
+ switch command.textEntryMode {
422
+ case "append":
423
+ return .append
424
+ case "replace":
425
+ return .replacement
426
+ default:
427
+ return command.clearFirst == true ? .replacement : .none
428
+ }
429
+ }
430
+
431
+ func typeTextReliably(
432
+ app: XCUIApplication,
433
+ target: TextEntryTarget,
434
+ text: String,
435
+ delaySeconds: Double,
436
+ repairMode: TextTypingRepairMode = .none
437
+ ) -> TextEntryResult {
438
+ guard !text.isEmpty else {
439
+ return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
440
+ }
441
+ var activeTarget = target
442
+ let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
443
+ activeTarget = activeTarget.withElement(initialTarget)
444
+ let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
445
+ let initialText = repairMode == .append ? currentText : nil
446
+ let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
447
+
448
+ if repairMode == .replacement {
449
+ guard let replacementTarget = initialTarget else {
450
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
451
+ }
452
+ if currentText == nil || currentText?.isEmpty == false {
453
+ clearTextInput(replacementTarget)
454
+ activeTarget = activeTarget.withElement(replacementTarget)
455
+ }
456
+ }
457
+
458
+ func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
459
+ if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
460
+ app.typeText(value)
461
+ return currentTarget
462
+ } else {
463
+ app.typeText(value)
464
+ return resolveTextEntryElement(app: app, target: activeTarget)
465
+ }
466
+ }
467
+
468
+ func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
469
+ guard let expectedValue else {
470
+ sleepFor(TextEntryTiming.pollInterval)
471
+ return
472
+ }
473
+ let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
474
+ while Date() < deadline {
475
+ if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
476
+ return
477
+ }
478
+ sleepFor(TextEntryTiming.pollInterval)
479
+ }
480
+ }
481
+
482
+ let characters = Array(text)
483
+ if delaySeconds > 0 && characters.count > 1 {
484
+ var typedTarget: XCUIElement?
485
+ for (index, character) in characters.enumerated() {
486
+ typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
487
+ if index + 1 < characters.count {
488
+ sleepFor(delaySeconds)
489
+ }
490
+ }
491
+ if repairMode == .none {
492
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
493
+ }
494
+ let repairResult = repairTextEntryIfNeeded(
495
+ app: app,
496
+ target: activeTarget.withElement(typedTarget),
497
+ expectedText: expectedText,
498
+ repairMode: repairMode
499
+ )
500
+ return verifyTextEntry(
501
+ app: app,
502
+ target: activeTarget.withElement(typedTarget),
503
+ expectedText: expectedText,
504
+ repaired: repairResult.repaired
505
+ )
506
+ }
507
+
508
+ let typedTarget: XCUIElement?
509
+ if repairMode != .none && characters.count > 1 {
510
+ let firstCharacter = String(characters[0])
511
+ var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
512
+ activeTarget = activeTarget.withElement(firstTypedTarget)
513
+ let warmupExpectedText = expectedTextEntryValue(
514
+ typedText: firstCharacter,
515
+ mode: repairMode,
516
+ initialText: initialText
517
+ )
518
+ waitForWarmupValue(warmupExpectedText, target: activeTarget)
519
+ let remainingText = String(characters.dropFirst())
520
+ firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
521
+ typedTarget = firstTypedTarget
522
+ } else {
523
+ typedTarget = typeIntoCurrentTarget(text)
524
+ }
525
+ if repairMode == .none {
526
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
527
+ }
528
+ let repairResult = repairTextEntryIfNeeded(
529
+ app: app,
530
+ target: activeTarget.withElement(typedTarget),
531
+ expectedText: expectedText,
532
+ repairMode: repairMode
533
+ )
534
+ return verifyTextEntry(
535
+ app: app,
536
+ target: activeTarget.withElement(typedTarget),
537
+ expectedText: expectedText,
538
+ repaired: repairResult.repaired
539
+ )
540
+ }
541
+
542
+ private func repairTextEntryIfNeeded(
543
+ app: XCUIApplication,
544
+ target: TextEntryTarget,
545
+ expectedText: String?,
546
+ repairMode: TextTypingRepairMode
547
+ ) -> TextEntryResult {
548
+ #if os(iOS)
549
+ guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
550
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
551
+ }
552
+ guard let expectedText else {
553
+ let observedText = editableTextValue(for: targetElement)
554
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
555
+ }
556
+ guard shouldRepairTextEntry(
557
+ app: app,
558
+ target: target,
559
+ expectedText: expectedText,
560
+ repairMode: repairMode
561
+ ) else {
562
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
563
+ }
564
+
565
+ guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
566
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
567
+ }
568
+ let observedText = editableTextValue(for: repairTarget) ?? ""
569
+ NSLog(
570
+ "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
571
+ expectedText.count,
572
+ observedText.count
573
+ )
574
+ clearTextInput(repairTarget)
575
+ app.typeText(expectedText)
576
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
577
+ #else
578
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
579
+ #endif
580
+ }
581
+
582
+ private func verifyTextEntry(
583
+ app: XCUIApplication,
584
+ target: TextEntryTarget,
585
+ expectedText: String?,
586
+ repaired: Bool
587
+ ) -> TextEntryResult {
588
+ let targetElement = resolveTextEntryElement(app: app, target: target)
589
+ guard let expectedText else {
590
+ return TextEntryResult(
591
+ verified: nil,
592
+ repaired: repaired,
593
+ expectedText: nil,
594
+ observedText: editableTextValue(for: targetElement)
595
+ )
596
+ }
597
+ guard let observedText = editableTextValue(for: targetElement) else {
598
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
599
+ }
600
+ guard observedText == expectedText else {
601
+ return TextEntryResult(
602
+ verified: false,
603
+ repaired: repaired,
604
+ expectedText: expectedText,
605
+ observedText: observedText
606
+ )
607
+ }
608
+ let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
609
+ var latestObservedText = observedText
610
+ while Date() < stableDeadline {
611
+ sleepFor(TextEntryTiming.pollInterval)
612
+ guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
613
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
614
+ }
615
+ latestObservedText = nextObservedText
616
+ guard nextObservedText == expectedText else {
617
+ return TextEntryResult(
618
+ verified: false,
619
+ repaired: repaired,
620
+ expectedText: expectedText,
621
+ observedText: nextObservedText
622
+ )
623
+ }
624
+ }
625
+ return TextEntryResult(
626
+ verified: true,
627
+ repaired: repaired,
628
+ expectedText: expectedText,
629
+ observedText: latestObservedText
630
+ )
631
+ }
632
+
633
+ private func expectedTextEntryValue(
634
+ typedText: String,
635
+ mode: TextTypingRepairMode,
636
+ initialText: String?
637
+ ) -> String? {
638
+ switch mode {
639
+ case .none:
640
+ return nil
641
+ case .append:
642
+ guard let initialText else {
643
+ return nil
644
+ }
645
+ return initialText + typedText
646
+ case .replacement:
647
+ return typedText
648
+ }
649
+ }
650
+
651
+ private func shouldRepairTextEntry(
652
+ app: XCUIApplication,
653
+ target: TextEntryTarget,
654
+ expectedText: String,
655
+ repairMode: TextTypingRepairMode
656
+ ) -> Bool {
657
+ #if os(iOS)
658
+ var latestObservedText: String?
659
+ let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
660
+ repeat {
661
+ guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
662
+ return false
663
+ }
664
+ if observedText == expectedText {
665
+ return false
666
+ }
667
+ latestObservedText = observedText
668
+ if !isRepairableTextEntryMismatch(
669
+ observedText: observedText,
670
+ expectedText: expectedText,
671
+ repairMode: repairMode
672
+ ) {
673
+ return false
674
+ }
675
+ sleepFor(TextEntryTiming.pollInterval)
676
+ } while Date() < deadline
677
+
678
+ guard let latestObservedText else {
679
+ return false
680
+ }
681
+ guard latestObservedText != expectedText else {
682
+ return false
683
+ }
684
+ return isRepairableTextEntryMismatch(
685
+ observedText: latestObservedText,
686
+ expectedText: expectedText,
687
+ repairMode: repairMode
688
+ )
689
+ #else
690
+ return false
691
+ #endif
692
+ }
693
+
694
+ private func isRepairableTextEntryMismatch(
695
+ observedText: String,
696
+ expectedText: String,
697
+ repairMode: TextTypingRepairMode
698
+ ) -> Bool {
699
+ guard observedText != expectedText else {
700
+ return false
701
+ }
702
+ if repairMode == .replacement {
703
+ return true
704
+ }
705
+ return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
706
+ observedText: observedText,
707
+ expectedText: expectedText
708
+ )
709
+ }
710
+
711
+ private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
712
+ guard observedText.count < expectedText.count else {
713
+ return false
714
+ }
715
+ let missingCharacterCount = expectedText.count - observedText.count
716
+ guard missingCharacterCount <= max(2, expectedText.count / 4) else {
717
+ return false
718
+ }
719
+ var expectedIndex = expectedText.startIndex
720
+ for character in observedText {
721
+ guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
722
+ return false
723
+ }
724
+ expectedIndex = expectedText.index(after: matchIndex)
725
+ }
726
+ return true
727
+ }
728
+
729
+ private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
730
+ if target.prefersFocusedElement {
731
+ if let focused = focusedTextInput(app: app) {
732
+ return focused
733
+ }
734
+ if let element = target.element, element.exists {
735
+ return element
736
+ }
737
+ } else {
738
+ if let element = target.element, element.exists {
739
+ return element
740
+ }
741
+ }
742
+ if let refreshPoint = target.refreshPoint,
743
+ let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
744
+ return refreshed
745
+ }
746
+ if let focused = focusedTextInput(app: app) {
747
+ return focused
748
+ }
749
+ return nil
750
+ }
751
+
752
+ private func waitForTextEntryReadiness(
753
+ app: XCUIApplication,
754
+ target: TextEntryTarget,
755
+ timeout: TimeInterval = TextEntryTiming.readinessTimeout
756
+ ) -> XCUIElement? {
757
+ #if os(iOS)
758
+ var latest = resolveTextEntryElement(app: app, target: target)
759
+ let deadline = Date().addingTimeInterval(timeout)
760
+ let hardwareKeyboardFallback = Date().addingTimeInterval(
761
+ min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
762
+ )
763
+ var sawSoftwareKeyboard = false
764
+ while Date() < deadline {
765
+ if let focused = focusedTextInput(app: app) {
766
+ latest = focused
767
+ if isKeyboardVisible(app: app) {
768
+ return focused
769
+ }
770
+ }
771
+ sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
772
+ if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
773
+ return latest
774
+ }
775
+ sleepFor(TextEntryTiming.pollInterval)
776
+ }
777
+ return focusedTextInput(app: app) ?? latest
778
+ #else
779
+ return resolveTextEntryElement(app: app, target: target)
780
+ #endif
781
+ }
782
+
783
+ private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
784
+ guard let element else {
785
+ return nil
786
+ }
787
+ let frame = element.frame
788
+ guard !frame.isEmpty else {
789
+ return nil
790
+ }
791
+ return CGPoint(x: frame.midX, y: frame.midY)
792
+ }
793
+
237
794
  func isKeyboardVisible(app: XCUIApplication) -> Bool {
238
- let keyboard = app.keyboards.firstMatch
239
- return keyboard.exists && !keyboard.frame.isEmpty
795
+ return visibleKeyboardFrame(app: app) != nil
796
+ }
797
+
798
+ private func keyboardElementExists(app: XCUIApplication) -> Bool {
799
+ #if os(iOS)
800
+ var exists = false
801
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
802
+ exists = app.keyboards.firstMatch.exists
803
+ })
804
+ if let exceptionMessage {
805
+ NSLog(
806
+ "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
807
+ exceptionMessage
808
+ )
809
+ return false
810
+ }
811
+ return exists
812
+ #else
813
+ return false
814
+ #endif
240
815
  }
241
816
 
242
817
  func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
@@ -272,7 +847,9 @@ extension RunnerTests {
272
847
  #if os(tvOS)
273
848
  return false
274
849
  #else
275
- let keyboardFrame = app.keyboards.firstMatch.frame
850
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
851
+ return false
852
+ }
276
853
  for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
277
854
  let candidates = [
278
855
  app.keyboards.buttons[label],
@@ -329,12 +906,49 @@ extension RunnerTests {
329
906
  }
330
907
 
331
908
  private func estimatedDeleteCount(for element: XCUIElement) -> Int {
332
- let valueText = String(describing: element.value ?? "")
333
- .trimmingCharacters(in: .whitespacesAndNewlines)
909
+ let valueText = normalizedElementText(element.value)
334
910
  let base = valueText.isEmpty ? 24 : (valueText.count + 8)
335
911
  return max(24, min(120, base))
336
912
  }
337
913
 
914
+ private func normalizedElementText(_ value: Any?) -> String {
915
+ String(describing: value ?? "")
916
+ .trimmingCharacters(in: .whitespacesAndNewlines)
917
+ }
918
+
919
+ private func editableTextValue(
920
+ for element: XCUIElement?,
921
+ treatingPlaceholderAsEmpty: Bool = false
922
+ ) -> String? {
923
+ guard let element else {
924
+ return nil
925
+ }
926
+ switch element.elementType {
927
+ case .textField, .searchField, .textView:
928
+ let value = String(describing: element.value ?? "")
929
+ if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
930
+ return ""
931
+ }
932
+ return value
933
+ case .secureTextField:
934
+ return nil
935
+ default:
936
+ return nil
937
+ }
938
+ }
939
+
940
+ private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
941
+ let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
942
+ guard !normalizedValue.isEmpty else {
943
+ return false
944
+ }
945
+ guard let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines),
946
+ !placeholder.isEmpty else {
947
+ return false
948
+ }
949
+ return normalizedValue == placeholder
950
+ }
951
+
338
952
  private func readableText(for element: XCUIElement) -> String? {
339
953
  let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
340
954
  let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -451,6 +1065,68 @@ extension RunnerTests {
451
1065
  return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
452
1066
  }
453
1067
 
1068
+ func keyboardAvoidingDragPoints(
1069
+ app: XCUIApplication,
1070
+ x: Double,
1071
+ y: Double,
1072
+ x2: Double,
1073
+ y2: Double
1074
+ ) -> DragPoints {
1075
+ let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
1076
+ #if os(iOS)
1077
+ guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
1078
+ return original
1079
+ }
1080
+ let minX = min(x, x2)
1081
+ let minY = min(y, y2)
1082
+ let gestureBounds = CGRect(
1083
+ x: CGFloat(minX),
1084
+ y: CGFloat(minY),
1085
+ width: CGFloat(max(abs(x2 - x), 1)),
1086
+ height: CGFloat(max(abs(y2 - y), 1))
1087
+ )
1088
+ guard gestureBounds.intersects(keyboardFrame) else {
1089
+ return original
1090
+ }
1091
+
1092
+ let window = app.windows.firstMatch
1093
+ let appFrame = window.exists && !window.frame.isEmpty ? window.frame : app.frame
1094
+ guard !appFrame.isEmpty else {
1095
+ return original
1096
+ }
1097
+
1098
+ let padding: Double = 12
1099
+ let targetMaxY = Double(keyboardFrame.minY) - padding
1100
+ let currentMaxY = max(y, y2)
1101
+ let shift = currentMaxY - targetMaxY
1102
+ guard shift > 0 else {
1103
+ return original
1104
+ }
1105
+
1106
+ let adjustedY = y - shift
1107
+ let adjustedY2 = y2 - shift
1108
+ guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
1109
+ return original
1110
+ }
1111
+
1112
+ NSLog(
1113
+ "AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
1114
+ x,
1115
+ y,
1116
+ x2,
1117
+ y2,
1118
+ x,
1119
+ adjustedY,
1120
+ x2,
1121
+ adjustedY2,
1122
+ Double(keyboardFrame.minY)
1123
+ )
1124
+ return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
1125
+ #else
1126
+ return original
1127
+ #endif
1128
+ }
1129
+
454
1130
  func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
455
1131
  let appFrame = app.frame
456
1132
  let referenceFrame = resolvedTouchReferenceFrame(app: app, appFrame: appFrame)
@@ -485,23 +1161,71 @@ extension RunnerTests {
485
1161
 
486
1162
  func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
487
1163
  let window = app.windows.firstMatch
488
- let windowFrame = window.frame
489
- if window.exists && !windowFrame.isEmpty {
490
- return windowFrame
1164
+ if window.exists {
1165
+ let windowFrame = window.frame
1166
+ if !windowFrame.isEmpty {
1167
+ return frameAvoidingKeyboard(app: app, frame: windowFrame)
1168
+ }
491
1169
  }
492
1170
  if !appFrame.isEmpty {
493
- return appFrame
1171
+ return frameAvoidingKeyboard(app: app, frame: appFrame)
494
1172
  }
495
1173
  return CGRect(x: 0, y: 0, width: 0, height: 0)
496
1174
  }
497
1175
 
1176
+ private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
1177
+ #if os(iOS)
1178
+ guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
1179
+ return frame
1180
+ }
1181
+ let intersection = frame.intersection(keyboardFrame)
1182
+ guard !intersection.isNull && intersection.height > 0 else {
1183
+ return frame
1184
+ }
1185
+ let keyboardCoverage = intersection.width / max(frame.width, 1)
1186
+ guard keyboardCoverage >= 0.5 else {
1187
+ return frame
1188
+ }
1189
+ let safeHeight = keyboardFrame.minY - frame.minY
1190
+ guard safeHeight >= frame.height * 0.25 else {
1191
+ return frame
1192
+ }
1193
+ return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
1194
+ #else
1195
+ return frame
1196
+ #endif
1197
+ }
1198
+
1199
+ private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
1200
+ #if os(iOS)
1201
+ var frame: CGRect?
1202
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1203
+ let keyboard = app.keyboards.firstMatch
1204
+ guard keyboard.exists else { return }
1205
+ let keyboardFrame = keyboard.frame
1206
+ guard !keyboardFrame.isEmpty else { return }
1207
+ frame = keyboardFrame
1208
+ })
1209
+ if let exceptionMessage {
1210
+ NSLog(
1211
+ "AGENT_DEVICE_RUNNER_KEYBOARD_FRAME_IGNORED_EXCEPTION=%@",
1212
+ exceptionMessage
1213
+ )
1214
+ return nil
1215
+ }
1216
+ return frame
1217
+ #else
1218
+ return nil
1219
+ #endif
1220
+ }
1221
+
498
1222
  func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
499
1223
  let total = max(count, 1)
500
1224
  let pause = max(pauseMs, 0)
501
1225
  for idx in 0..<total {
502
1226
  operation(idx)
503
1227
  if idx < total - 1 && pause > 0 {
504
- Thread.sleep(forTimeInterval: pause / 1000.0)
1228
+ sleepFor(pause / 1000.0)
505
1229
  }
506
1230
  }
507
1231
  }