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