agent-device 0.16.8 → 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.
@@ -0,0 +1,743 @@
1
+ import XCTest
2
+
3
+ // Text entry & keyboard-readiness for the runner: the focus -> type -> verify -> repair
4
+ // pipeline, readiness polling, and field clearing. Behavior-preserving extraction from
5
+ // RunnerTests+Interaction.swift (no logic changes) to keep that file navigable.
6
+ extension RunnerTests {
7
+ enum TextTypingRepairMode {
8
+ case none
9
+ case append
10
+ case replacement
11
+ }
12
+
13
+ enum TextEntryTiming {
14
+ static let focusTimeout: TimeInterval = 0.4
15
+ static let repairReadinessTimeout: TimeInterval = 1.0
16
+ static let readinessTimeout: TimeInterval = 2.0
17
+ static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
18
+ static let pollInterval: TimeInterval = 0.02
19
+ static let warmupValueTimeout: TimeInterval = 0.4
20
+ static let verificationStabilityWindow: TimeInterval = 0.2
21
+ }
22
+
23
+ struct TextEntryResult {
24
+ let verified: Bool?
25
+ let repaired: Bool
26
+ let expectedText: String?
27
+ let observedText: String?
28
+ }
29
+
30
+ struct TextEntryTarget {
31
+ let element: XCUIElement?
32
+ let refreshPoint: CGPoint?
33
+ let prefersFocusedElement: Bool
34
+
35
+ func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
36
+ guard let nextElement else {
37
+ return self
38
+ }
39
+ let frame = nextElement.frame
40
+ let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
41
+ return TextEntryTarget(
42
+ element: nextElement,
43
+ refreshPoint: point,
44
+ prefersFocusedElement: prefersFocusedElement
45
+ )
46
+ }
47
+ }
48
+
49
+ func clearTextInput(_ element: XCUIElement) {
50
+ // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
51
+ // field is empty. Why skip: the edge-tap computes a point from the element frame, which can
52
+ // be stale after the field repositions on focus (e.g. the Settings search bar jumps
53
+ // bottom->top and reveals a "Suggestions" list) — tapping there navigates away instead of
54
+ // clearing; and replacing into an already-empty field is a no-op anyway.
55
+ // editableTextValue returns nil for secure (and unknown) fields, where we CANNOT confirm
56
+ // emptiness — those must still be cleared, or replace would concatenate stale + new text.
57
+ // So distinguish nil (clear) from "" (skip).
58
+ if let existing = editableTextValue(for: element, treatingPlaceholderAsEmpty: true),
59
+ existing.isEmpty {
60
+ return
61
+ }
62
+ #if !os(tvOS)
63
+ moveCaretToEnd(element: element)
64
+ #endif
65
+ let count = estimatedDeleteCount(for: element)
66
+ let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
67
+ element.typeText(deletes)
68
+ }
69
+
70
+ func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
71
+ #if os(iOS)
72
+ // iOS focus predicates can return stale or misleading text-input matches
73
+ // under XCUITest, so text entry readiness is driven by tap/keyboard state.
74
+ return nil
75
+ #else
76
+ var focused: XCUIElement?
77
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
78
+ let candidates = app
79
+ .descendants(matching: .any)
80
+ .matching(NSPredicate(format: "hasKeyboardFocus == 1"))
81
+ .allElementsBoundByIndex
82
+ for candidate in candidates where candidate.exists {
83
+ switch candidate.elementType {
84
+ case .textField, .secureTextField, .searchField, .textView:
85
+ focused = candidate
86
+ return
87
+ default:
88
+ continue
89
+ }
90
+ }
91
+ })
92
+ if let exceptionMessage {
93
+ NSLog(
94
+ "AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
95
+ exceptionMessage
96
+ )
97
+ return nil
98
+ }
99
+ return focused
100
+ #endif
101
+ }
102
+
103
+ func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
104
+ #if os(tvOS)
105
+ return target
106
+ #else
107
+ let latest = target
108
+ let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
109
+ let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
110
+ while Date() < deadline {
111
+ if let focused = focusedTextInput(app: app) {
112
+ return focused
113
+ }
114
+ // focusedTextInput is intentionally nil on iOS; treat the keyboard transitioning to
115
+ // visible after our tap as the focus-moved signal. Don't fast-path when it was already up.
116
+ if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
117
+ return latest
118
+ }
119
+ sleepFor(TextEntryTiming.pollInterval)
120
+ }
121
+ return latest
122
+ #endif
123
+ }
124
+
125
+ func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
126
+ guard let x, let y else {
127
+ let focused = waitForTextEntryReadiness(
128
+ app: app,
129
+ target: TextEntryTarget(
130
+ element: focusedTextInput(app: app),
131
+ refreshPoint: nil,
132
+ prefersFocusedElement: true
133
+ )
134
+ )
135
+ return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
136
+ }
137
+
138
+ let target = textInputAt(app: app, x: x, y: y)
139
+ let requestedPoint = CGPoint(x: x, y: y)
140
+ if let target {
141
+ let frame = target.frame
142
+ if !frame.isEmpty {
143
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
144
+ } else {
145
+ _ = tapAt(app: app, x: x, y: y)
146
+ }
147
+ } else {
148
+ _ = tapAt(app: app, x: x, y: y)
149
+ }
150
+ let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
151
+ let element = waitForTextEntryReadiness(
152
+ app: app,
153
+ target: TextEntryTarget(
154
+ element: stabilized ?? target,
155
+ refreshPoint: requestedPoint,
156
+ prefersFocusedElement: false
157
+ )
158
+ ) ?? stabilized ?? target
159
+ return TextEntryTarget(
160
+ element: element,
161
+ refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
162
+ prefersFocusedElement: false
163
+ )
164
+ }
165
+
166
+ func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
167
+ let point = textEntryRefreshPoint(for: element)
168
+ if let point {
169
+ _ = tapAt(app: app, x: point.x, y: point.y)
170
+ }
171
+ let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
172
+ let resolved = waitForTextEntryReadiness(
173
+ app: app,
174
+ target: TextEntryTarget(
175
+ element: stabilized ?? element,
176
+ refreshPoint: point,
177
+ prefersFocusedElement: false
178
+ )
179
+ ) ?? stabilized ?? element
180
+ return TextEntryTarget(
181
+ element: resolved,
182
+ refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
183
+ prefersFocusedElement: false
184
+ )
185
+ }
186
+
187
+ func isTextEntryElement(_ element: XCUIElement) -> Bool {
188
+ switch element.elementType {
189
+ case .textField, .secureTextField, .searchField, .textView:
190
+ return true
191
+ default:
192
+ return false
193
+ }
194
+ }
195
+
196
+ func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
197
+ switch command.textEntryMode {
198
+ case "append":
199
+ return .append
200
+ case "replace":
201
+ return .replacement
202
+ default:
203
+ return command.clearFirst == true ? .replacement : .none
204
+ }
205
+ }
206
+
207
+ func typeTextReliably(
208
+ app: XCUIApplication,
209
+ target: TextEntryTarget,
210
+ text: String,
211
+ delaySeconds: Double,
212
+ repairMode: TextTypingRepairMode = .none
213
+ ) -> TextEntryResult {
214
+ guard !text.isEmpty else {
215
+ return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
216
+ }
217
+ var activeTarget = target
218
+ let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
219
+ activeTarget = activeTarget.withElement(initialTarget)
220
+ let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
221
+ let initialText = repairMode == .append ? currentText : nil
222
+ let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
223
+
224
+ if repairMode == .replacement {
225
+ guard let replacementTarget = initialTarget else {
226
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
227
+ }
228
+ if currentText == nil || currentText?.isEmpty == false {
229
+ clearTextInput(replacementTarget)
230
+ activeTarget = activeTarget.withElement(replacementTarget)
231
+ }
232
+ }
233
+
234
+ func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
235
+ if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
236
+ app.typeText(value)
237
+ return currentTarget
238
+ } else {
239
+ app.typeText(value)
240
+ return resolveTextEntryElement(app: app, target: activeTarget)
241
+ }
242
+ }
243
+
244
+ func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
245
+ guard let expectedValue else {
246
+ sleepFor(TextEntryTiming.pollInterval)
247
+ return
248
+ }
249
+ let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
250
+ while Date() < deadline {
251
+ if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
252
+ return
253
+ }
254
+ sleepFor(TextEntryTiming.pollInterval)
255
+ }
256
+ }
257
+
258
+ let characters = Array(text)
259
+ if delaySeconds > 0 && characters.count > 1 {
260
+ var typedTarget: XCUIElement?
261
+ for (index, character) in characters.enumerated() {
262
+ typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
263
+ if index + 1 < characters.count {
264
+ sleepFor(delaySeconds)
265
+ }
266
+ }
267
+ if repairMode == .none {
268
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
269
+ }
270
+ let repairResult = repairTextEntryIfNeeded(
271
+ app: app,
272
+ target: activeTarget.withElement(typedTarget),
273
+ expectedText: expectedText,
274
+ repairMode: repairMode
275
+ )
276
+ return verifyTextEntry(
277
+ app: app,
278
+ target: activeTarget.withElement(typedTarget),
279
+ expectedText: expectedText,
280
+ repaired: repairResult.repaired
281
+ )
282
+ }
283
+
284
+ let typedTarget: XCUIElement?
285
+ if repairMode != .none && characters.count > 1 {
286
+ let firstCharacter = String(characters[0])
287
+ var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
288
+ activeTarget = activeTarget.withElement(firstTypedTarget)
289
+ let warmupExpectedText = expectedTextEntryValue(
290
+ typedText: firstCharacter,
291
+ mode: repairMode,
292
+ initialText: initialText
293
+ )
294
+ waitForWarmupValue(warmupExpectedText, target: activeTarget)
295
+ let remainingText = String(characters.dropFirst())
296
+ firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
297
+ typedTarget = firstTypedTarget
298
+ } else {
299
+ typedTarget = typeIntoCurrentTarget(text)
300
+ }
301
+ if repairMode == .none {
302
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
303
+ }
304
+ let repairResult = repairTextEntryIfNeeded(
305
+ app: app,
306
+ target: activeTarget.withElement(typedTarget),
307
+ expectedText: expectedText,
308
+ repairMode: repairMode
309
+ )
310
+ return verifyTextEntry(
311
+ app: app,
312
+ target: activeTarget.withElement(typedTarget),
313
+ expectedText: expectedText,
314
+ repaired: repairResult.repaired
315
+ )
316
+ }
317
+
318
+ private func repairTextEntryIfNeeded(
319
+ app: XCUIApplication,
320
+ target: TextEntryTarget,
321
+ expectedText: String?,
322
+ repairMode: TextTypingRepairMode
323
+ ) -> TextEntryResult {
324
+ #if os(iOS)
325
+ guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
326
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
327
+ }
328
+ guard let expectedText else {
329
+ let observedText = editableTextValue(for: targetElement)
330
+ return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
331
+ }
332
+ guard shouldRepairTextEntry(
333
+ app: app,
334
+ target: target,
335
+ expectedText: expectedText,
336
+ repairMode: repairMode
337
+ ) else {
338
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
339
+ }
340
+
341
+ guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
342
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
343
+ }
344
+ let observedText = editableTextValue(for: repairTarget) ?? ""
345
+ NSLog(
346
+ "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
347
+ expectedText.count,
348
+ observedText.count
349
+ )
350
+ clearTextInput(repairTarget)
351
+ app.typeText(expectedText)
352
+ return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
353
+ #else
354
+ return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
355
+ #endif
356
+ }
357
+
358
+ private func verifyTextEntry(
359
+ app: XCUIApplication,
360
+ target: TextEntryTarget,
361
+ expectedText: String?,
362
+ repaired: Bool
363
+ ) -> TextEntryResult {
364
+ let targetElement = resolveTextEntryElement(app: app, target: target)
365
+ guard let expectedText else {
366
+ return TextEntryResult(
367
+ verified: nil,
368
+ repaired: repaired,
369
+ expectedText: nil,
370
+ observedText: editableTextValue(for: targetElement)
371
+ )
372
+ }
373
+ guard let observedText = editableTextValue(for: targetElement) else {
374
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
375
+ }
376
+ guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
377
+ return TextEntryResult(
378
+ verified: false,
379
+ repaired: repaired,
380
+ expectedText: expectedText,
381
+ observedText: observedText
382
+ )
383
+ }
384
+ let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
385
+ var latestObservedText = observedText
386
+ while Date() < stableDeadline {
387
+ sleepFor(TextEntryTiming.pollInterval)
388
+ guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
389
+ return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
390
+ }
391
+ latestObservedText = nextObservedText
392
+ guard textEntryValueMatchesExpected(
393
+ resolveTextEntryElement(app: app, target: target),
394
+ observedText: nextObservedText,
395
+ expectedText: expectedText
396
+ ) else {
397
+ return TextEntryResult(
398
+ verified: false,
399
+ repaired: repaired,
400
+ expectedText: expectedText,
401
+ observedText: nextObservedText
402
+ )
403
+ }
404
+ }
405
+ return TextEntryResult(
406
+ verified: true,
407
+ repaired: repaired,
408
+ expectedText: expectedText,
409
+ observedText: latestObservedText
410
+ )
411
+ }
412
+
413
+ private func textEntryValueMatchesExpected(
414
+ _ element: XCUIElement?,
415
+ observedText: String,
416
+ expectedText: String
417
+ ) -> Bool {
418
+ if observedText == expectedText {
419
+ return true
420
+ }
421
+ guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
422
+ return false
423
+ }
424
+ var submittedText = expectedText
425
+ while hasTextEntrySubmitSuffix(submittedText) {
426
+ submittedText.removeLast()
427
+ }
428
+ return observedText == submittedText
429
+ }
430
+
431
+ private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
432
+ text.hasSuffix("\n") || text.hasSuffix("\r")
433
+ }
434
+
435
+ private func expectedTextEntryValue(
436
+ typedText: String,
437
+ mode: TextTypingRepairMode,
438
+ initialText: String?
439
+ ) -> String? {
440
+ switch mode {
441
+ case .none:
442
+ return nil
443
+ case .append:
444
+ guard let initialText else {
445
+ return nil
446
+ }
447
+ return initialText + typedText
448
+ case .replacement:
449
+ return typedText
450
+ }
451
+ }
452
+
453
+ private func shouldRepairTextEntry(
454
+ app: XCUIApplication,
455
+ target: TextEntryTarget,
456
+ expectedText: String,
457
+ repairMode: TextTypingRepairMode
458
+ ) -> Bool {
459
+ #if os(iOS)
460
+ var latestObservedText: String?
461
+ let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
462
+ repeat {
463
+ guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
464
+ return false
465
+ }
466
+ if textEntryValueMatchesExpected(
467
+ resolveTextEntryElement(app: app, target: target),
468
+ observedText: observedText,
469
+ expectedText: expectedText
470
+ ) {
471
+ return false
472
+ }
473
+ latestObservedText = observedText
474
+ if !isRepairableTextEntryMismatch(
475
+ observedText: observedText,
476
+ expectedText: expectedText,
477
+ repairMode: repairMode
478
+ ) {
479
+ return false
480
+ }
481
+ sleepFor(TextEntryTiming.pollInterval)
482
+ } while Date() < deadline
483
+
484
+ guard let latestObservedText else {
485
+ return false
486
+ }
487
+ guard !textEntryValueMatchesExpected(
488
+ resolveTextEntryElement(app: app, target: target),
489
+ observedText: latestObservedText,
490
+ expectedText: expectedText
491
+ ) else {
492
+ return false
493
+ }
494
+ return isRepairableTextEntryMismatch(
495
+ observedText: latestObservedText,
496
+ expectedText: expectedText,
497
+ repairMode: repairMode
498
+ )
499
+ #else
500
+ return false
501
+ #endif
502
+ }
503
+
504
+ private func isRepairableTextEntryMismatch(
505
+ observedText: String,
506
+ expectedText: String,
507
+ repairMode: TextTypingRepairMode
508
+ ) -> Bool {
509
+ guard observedText != expectedText else {
510
+ return false
511
+ }
512
+ if repairMode == .replacement {
513
+ return true
514
+ }
515
+ return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
516
+ observedText: observedText,
517
+ expectedText: expectedText
518
+ )
519
+ }
520
+
521
+ private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
522
+ guard observedText.count < expectedText.count else {
523
+ return false
524
+ }
525
+ let missingCharacterCount = expectedText.count - observedText.count
526
+ guard missingCharacterCount <= max(2, expectedText.count / 4) else {
527
+ return false
528
+ }
529
+ var expectedIndex = expectedText.startIndex
530
+ for character in observedText {
531
+ guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
532
+ return false
533
+ }
534
+ expectedIndex = expectedText.index(after: matchIndex)
535
+ }
536
+ return true
537
+ }
538
+
539
+ private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
540
+ if target.prefersFocusedElement {
541
+ if let focused = focusedTextInput(app: app) {
542
+ return focused
543
+ }
544
+ if let element = target.element, element.exists {
545
+ return element
546
+ }
547
+ } else {
548
+ if let element = target.element, element.exists {
549
+ return element
550
+ }
551
+ }
552
+ if let refreshPoint = target.refreshPoint,
553
+ let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
554
+ return refreshed
555
+ }
556
+ if let focused = focusedTextInput(app: app) {
557
+ return focused
558
+ }
559
+ return nil
560
+ }
561
+
562
+ private func waitForTextEntryReadiness(
563
+ app: XCUIApplication,
564
+ target: TextEntryTarget,
565
+ timeout: TimeInterval = TextEntryTiming.readinessTimeout
566
+ ) -> XCUIElement? {
567
+ #if os(iOS)
568
+ var latest = resolveTextEntryElement(app: app, target: target)
569
+ let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
570
+ let deadline = Date().addingTimeInterval(timeout)
571
+ let hardwareKeyboardFallback = Date().addingTimeInterval(
572
+ min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
573
+ )
574
+ var sawSoftwareKeyboard = false
575
+ while Date() < deadline {
576
+ if let focused = focusedTextInput(app: app) {
577
+ latest = focused
578
+ if isKeyboardVisible(app: app) {
579
+ return focused
580
+ }
581
+ }
582
+ // Fast-path on a keyboard hidden->visible transition: our tapped field gained focus, so
583
+ // return immediately instead of burning the full readinessTimeout (warmup-first-char echo
584
+ // + post-type verify/repair remain as drop safety nets). When the keyboard was ALREADY up
585
+ // (back-to-back fills), this isn't a focus signal — fall through to the settle/timeout so
586
+ // text isn't sent to the previously-focused field.
587
+ if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
588
+ return latest
589
+ }
590
+ sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
591
+ if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
592
+ return latest
593
+ }
594
+ sleepFor(TextEntryTiming.pollInterval)
595
+ }
596
+ return focusedTextInput(app: app) ?? latest
597
+ #else
598
+ return resolveTextEntryElement(app: app, target: target)
599
+ #endif
600
+ }
601
+
602
+ func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {
603
+ #if os(iOS)
604
+ switch element.elementType {
605
+ case .textField, .secureTextField, .searchField, .textView:
606
+ if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil {
607
+ return
608
+ }
609
+ let frame = element.frame
610
+ if !frame.isEmpty {
611
+ _ = tapAt(app: app, x: frame.midX, y: frame.midY)
612
+ _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout)
613
+ }
614
+ default:
615
+ return
616
+ }
617
+ #endif
618
+ }
619
+
620
+ private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
621
+ let deadline = Date().addingTimeInterval(timeout)
622
+ while Date() < deadline {
623
+ if let focused = focusedTextInput(app: app) {
624
+ return focused
625
+ }
626
+ sleepFor(TextEntryTiming.pollInterval)
627
+ }
628
+ return focusedTextInput(app: app)
629
+ }
630
+
631
+ private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
632
+ guard let element else {
633
+ return nil
634
+ }
635
+ let frame = element.frame
636
+ guard !frame.isEmpty else {
637
+ return nil
638
+ }
639
+ return CGPoint(x: frame.midX, y: frame.midY)
640
+ }
641
+
642
+ /// A focus-moved signal for iOS text entry, where `focusedTextInput` is intentionally nil.
643
+ /// The software keyboard TRANSITIONING from hidden (at entry) to visible means the field we
644
+ /// just tapped gained first-responder. If the keyboard was ALREADY up (e.g. back-to-back
645
+ /// fills into different fields), its visibility is not evidence focus moved to the new field,
646
+ /// so callers must keep waiting rather than typing into the previously-focused field.
647
+ private func keyboardBecameVisible(app: XCUIApplication, wasVisibleAtEntry: Bool) -> Bool {
648
+ return !wasVisibleAtEntry && isKeyboardVisible(app: app)
649
+ }
650
+
651
+ private func keyboardElementExists(app: XCUIApplication) -> Bool {
652
+ #if os(iOS)
653
+ var exists = false
654
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
655
+ exists = app.keyboards.firstMatch.exists
656
+ })
657
+ if let exceptionMessage {
658
+ NSLog(
659
+ "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
660
+ exceptionMessage
661
+ )
662
+ return false
663
+ }
664
+ return exists
665
+ #else
666
+ return false
667
+ #endif
668
+ }
669
+
670
+ private func moveCaretToEnd(element: XCUIElement) {
671
+ #if os(tvOS)
672
+ return
673
+ #else
674
+ let frame = element.frame
675
+ guard !frame.isEmpty else {
676
+ element.tap()
677
+ return
678
+ }
679
+ let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
680
+ let target = origin.withOffset(
681
+ CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
682
+ )
683
+ target.tap()
684
+ #endif
685
+ }
686
+
687
+ private func estimatedDeleteCount(for element: XCUIElement) -> Int {
688
+ let valueText = normalizedElementText(element.value)
689
+ let base = valueText.isEmpty ? 24 : (valueText.count + 8)
690
+ return max(24, min(120, base))
691
+ }
692
+
693
+ private func normalizedElementText(_ value: Any?) -> String {
694
+ String(describing: value ?? "")
695
+ .trimmingCharacters(in: .whitespacesAndNewlines)
696
+ }
697
+
698
+ private func editableTextValue(
699
+ for element: XCUIElement?,
700
+ treatingPlaceholderAsEmpty: Bool = false
701
+ ) -> String? {
702
+ guard let element else {
703
+ return nil
704
+ }
705
+ switch element.elementType {
706
+ case .textField, .searchField, .textView:
707
+ let value = String(describing: element.value ?? "")
708
+ if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
709
+ return ""
710
+ }
711
+ return value
712
+ case .secureTextField:
713
+ return nil
714
+ default:
715
+ return nil
716
+ }
717
+ }
718
+
719
+ private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
720
+ let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
721
+ guard !normalizedValue.isEmpty else {
722
+ return false
723
+ }
724
+ let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
725
+ if !placeholder.isEmpty && normalizedValue == placeholder {
726
+ return true
727
+ }
728
+ if isGenericTextInputLabel(normalizedValue) {
729
+ return true
730
+ }
731
+ let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
732
+ return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
733
+ }
734
+
735
+ private func isGenericTextInputLabel(_ value: String) -> Bool {
736
+ switch value {
737
+ case "Text input field":
738
+ return true
739
+ default:
740
+ return false
741
+ }
742
+ }
743
+ }