agent-device 0.20.4 → 0.20.6

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 (100) hide show
  1. package/README.md +2 -0
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.4.apk → agent-device-android-ime-helper-0.20.6.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.6.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.4.manifest.json → agent-device-android-ime-helper-0.20.6.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.4.apk → agent-device-android-snapshot-helper-0.20.6.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.6.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.4.manifest.json → agent-device-android-snapshot-helper-0.20.6.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +28 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +41 -1
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +298 -92
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +126 -187
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +32 -0
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +212 -0
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +118 -8
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +134 -27
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +23 -5
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +10 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +0 -67
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +11 -1
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +2 -22
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +48 -11
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +0 -20
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +177 -0
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +105 -448
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +4 -0
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +587 -0
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +18 -0
  29. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +72 -0
  30. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +89 -0
  31. package/dist/src/agent-device-client.js +2 -2
  32. package/dist/src/android.js +1 -1
  33. package/dist/src/app-log-request-scope.js +1 -1
  34. package/dist/src/app-resolution.js +1 -1
  35. package/dist/src/apps.js +5 -5
  36. package/dist/src/cli-config.js +1 -1
  37. package/dist/src/cli-help.js +34 -24
  38. package/dist/src/cli-runner.js +1 -1
  39. package/dist/src/cli.js +15 -15
  40. package/dist/src/command-metadata.js +1 -1
  41. package/dist/src/command-schema.js +3 -3
  42. package/dist/src/context.js +1 -1
  43. package/dist/src/debug-symbols.js +1 -1
  44. package/dist/src/find.js +1 -1
  45. package/dist/src/finders.js +1 -1
  46. package/dist/src/generic-settle.js +1 -0
  47. package/dist/src/generic.js +1 -1
  48. package/dist/src/ime-lifecycle.js +3 -3
  49. package/dist/src/index.d.ts +27 -7
  50. package/dist/src/input-actions.js +4 -4
  51. package/dist/src/interaction-runtime.js +1 -0
  52. package/dist/src/interaction-snapshot.js +1 -1
  53. package/dist/src/interaction-touch-reference-frame.js +1 -0
  54. package/dist/src/interaction.js +1 -1
  55. package/dist/src/interactor.js +2 -2
  56. package/dist/src/interactor2.js +1 -1
  57. package/dist/src/internal/bin.js +2 -2
  58. package/dist/src/internal/daemon.js +15 -15
  59. package/dist/src/lease-context.js +1 -1
  60. package/dist/src/lease.js +1 -1
  61. package/dist/src/limrun.d.ts +23 -2
  62. package/dist/src/linux.js +1 -1
  63. package/dist/src/process-exit.js +1 -0
  64. package/dist/src/react-native.js +1 -1
  65. package/dist/src/record-trace.js +1 -1
  66. package/dist/src/register-builtins.js +1 -1
  67. package/dist/src/registry.js +25 -24
  68. package/dist/src/remote-config.js +1 -1
  69. package/dist/src/remote-config2.js +1 -1
  70. package/dist/src/reporting.js +2 -2
  71. package/dist/src/runner-client.js +1 -1
  72. package/dist/src/runner-disposal.js +1 -1
  73. package/dist/src/runner-lease.js +1 -1
  74. package/dist/src/runtime.js +3 -3
  75. package/dist/src/screenshot-geometry.js +1 -1
  76. package/dist/src/screenshot-result.js +4 -4
  77. package/dist/src/sdk-android-adb.d.ts +20 -2
  78. package/dist/src/sdk-android-adb.js +1 -1
  79. package/dist/src/sdk-batch-runner.js +3 -3
  80. package/dist/src/sdk-contracts.d.ts +17 -2
  81. package/dist/src/sdk-finders.d.ts +4 -20
  82. package/dist/src/sdk-finders.js +1 -1
  83. package/dist/src/{interaction.d.ts → sdk-selectors.d.ts} +98 -3
  84. package/dist/src/sdk-selectors.js +1 -1
  85. package/dist/src/selector-runtime.js +1 -1
  86. package/dist/src/selector-vocabulary.js +1 -1
  87. package/dist/src/selectors.d.ts +1 -46
  88. package/dist/src/selectors.js +1 -1
  89. package/dist/src/server.js +3 -3
  90. package/dist/src/session-target-evidence.js +1 -1
  91. package/dist/src/session.js +9 -8
  92. package/dist/src/snapshot2.js +1 -1
  93. package/dist/src/text-surface.js +1 -0
  94. package/dist/src/viewport-dimension.js +13 -12
  95. package/dist/src/web.js +1 -1
  96. package/package.json +42 -34
  97. package/server.json +3 -3
  98. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.4.apk.sha256 +0 -1
  99. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk.sha256 +0 -1
  100. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -225
@@ -4,6 +4,29 @@ import XCTest
4
4
  // pipeline, readiness polling, and field clearing. Behavior-preserving extraction from
5
5
  // RunnerTests+Interaction.swift (no logic changes) to keep that file navigable.
6
6
  extension RunnerTests {
7
+ enum TextEntryFailure: String {
8
+ case notFocused = "TEXT_INPUT_NOT_FOCUSED"
9
+ case synthesisUnavailable = "TEXT_INPUT_SYNTHESIS_UNAVAILABLE"
10
+
11
+ var message: String {
12
+ switch self {
13
+ case .notFocused:
14
+ return "No focused text input was available for typing."
15
+ case .synthesisUnavailable:
16
+ return "Reliable text synthesis is unavailable while the software keyboard is hidden."
17
+ }
18
+ }
19
+
20
+ var hint: String {
21
+ switch self {
22
+ case .notFocused:
23
+ return "Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing."
24
+ case .synthesisUnavailable:
25
+ return "Show the software keyboard, then retry type or fill."
26
+ }
27
+ }
28
+ }
29
+
7
30
  enum TextTypingRepairMode {
8
31
  case none
9
32
  case append
@@ -24,12 +47,27 @@ extension RunnerTests {
24
47
  let repaired: Bool
25
48
  let expectedText: String?
26
49
  let observedText: String?
50
+ var textEntryRoute: String? = nil
51
+ var failure: TextEntryFailure? = nil
27
52
  }
28
53
 
29
54
  struct TextEntryTarget {
30
55
  let element: XCUIElement?
31
56
  let refreshPoint: CGPoint?
32
57
  let prefersFocusedElement: Bool
58
+ let fromTapWitness: Bool
59
+
60
+ init(
61
+ element: XCUIElement?,
62
+ refreshPoint: CGPoint?,
63
+ prefersFocusedElement: Bool,
64
+ fromTapWitness: Bool = false
65
+ ) {
66
+ self.element = element
67
+ self.refreshPoint = refreshPoint
68
+ self.prefersFocusedElement = prefersFocusedElement
69
+ self.fromTapWitness = fromTapWitness
70
+ }
33
71
 
34
72
  func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
35
73
  guard let nextElement else {
@@ -40,7 +78,8 @@ extension RunnerTests {
40
78
  return TextEntryTarget(
41
79
  element: nextElement,
42
80
  refreshPoint: point,
43
- prefersFocusedElement: prefersFocusedElement
81
+ prefersFocusedElement: prefersFocusedElement,
82
+ fromTapWitness: fromTapWitness
44
83
  )
45
84
  }
46
85
  }
@@ -50,6 +89,16 @@ extension RunnerTests {
50
89
  let focusConfirmed: Bool
51
90
  }
52
91
 
92
+ struct TextEntryTapWitness {
93
+ let element: XCUIElement
94
+ let bundleId: String?
95
+ let processIdentifier: Int?
96
+
97
+ func matches(bundleId: String?, processIdentifier: Int?) -> Bool {
98
+ self.bundleId == bundleId && self.processIdentifier == processIdentifier
99
+ }
100
+ }
101
+
53
102
  func clearTextInput(_ element: XCUIElement) {
54
103
  // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
55
104
  // field is empty. Why skip: the edge-tap computes a point from the element frame, which can
@@ -95,6 +144,54 @@ extension RunnerTests {
95
144
  #endif
96
145
  }
97
146
 
147
+ func rememberTextEntryTap(_ element: XCUIElement?) {
148
+ guard let element, isTextEntryElement(element) else {
149
+ clearRememberedTextEntryTap()
150
+ return
151
+ }
152
+ textEntryTapWitness = TextEntryTapWitness(
153
+ element: element,
154
+ bundleId: currentBundleId,
155
+ processIdentifier: currentAppProcessIdentifier
156
+ )
157
+ }
158
+
159
+ func clearRememberedTextEntryTap() {
160
+ textEntryTapWitness = nil
161
+ }
162
+
163
+ private func rememberedTextEntryTarget() -> TextEntryTarget? {
164
+ guard let witness = textEntryTapWitness else {
165
+ return nil
166
+ }
167
+ // The tap is proof for one immediately-following bare type only. Consume it before checking
168
+ // the element so a failed or interrupted type cannot reuse stale focus evidence.
169
+ clearRememberedTextEntryTap()
170
+ guard witness.matches(
171
+ bundleId: currentBundleId,
172
+ processIdentifier: currentAppProcessIdentifier
173
+ ) else {
174
+ return nil
175
+ }
176
+ let element = witness.element
177
+ // XCUIElement is query-backed rather than a stable node identity. A same-identifier field
178
+ // introduced by app-side navigation between tap and this immediate type can therefore
179
+ // re-resolve here; keep the witness one-shot and fail closed on every observable identity
180
+ // boundary instead of using frame equality, which would reject legitimate layout changes.
181
+ guard safely("LAST_TAPPED_TEXT_INPUT_EXISTS", false, { element.exists }) else {
182
+ return nil
183
+ }
184
+ // Keep the target scoped to the element that the preceding tap actually selected. Do not
185
+ // attach a refresh point: if that element disappeared, bare type must fail closed rather
186
+ // than rediscovering a different field or dispatching unscoped app.typeText.
187
+ return TextEntryTarget(
188
+ element: element,
189
+ refreshPoint: nil,
190
+ prefersFocusedElement: false,
191
+ fromTapWitness: true
192
+ )
193
+ }
194
+
98
195
  func stabilizeTextInputBeforeTyping(
99
196
  app: XCUIApplication,
100
197
  target: XCUIElement?,
@@ -123,11 +220,15 @@ extension RunnerTests {
123
220
 
124
221
  func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
125
222
  guard let x, let y else {
223
+ let softwareKeyboardVisible = isKeyboardVisible(app: app)
224
+ if !softwareKeyboardVisible, let rememberedTarget = rememberedTextEntryTarget() {
225
+ return rememberedTarget
226
+ }
126
227
  // Bare `type` targets the current first responder. On iOS we intentionally do not trust
127
228
  // `hasKeyboardFocus`, but an already-visible software keyboard is sufficient evidence that
128
229
  // app.typeText has a receiver; waiting the full readiness timeout cannot prove a stronger
129
230
  // target because there is no selector/coordinate focus move to validate.
130
- if isKeyboardVisible(app: app) {
231
+ if softwareKeyboardVisible {
131
232
  return TextEntryTarget(
132
233
  element: focusedTextInput(app: app),
133
234
  refreshPoint: nil,
@@ -244,451 +345,7 @@ extension RunnerTests {
244
345
  }
245
346
  }
246
347
 
247
- func typeTextReliably(
248
- app: XCUIApplication,
249
- target: TextEntryTarget,
250
- text: String,
251
- delaySeconds: Double,
252
- repairMode: TextTypingRepairMode = .none,
253
- commandId: String? = nil
254
- ) -> TextEntryResult {
255
- let totalStartedAt = Date()
256
- guard !text.isEmpty else {
257
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: 0, mode: repairMode)
258
- return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
259
- }
260
- var activeTarget = target
261
- let initialResolveStartedAt = Date()
262
- let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
263
- activeTarget = activeTarget.withElement(initialTarget)
264
- let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
265
- let initialText = repairMode == .append ? currentText : nil
266
- let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
267
- logTextEntryPhase(
268
- commandId: commandId,
269
- phase: "initial-resolve",
270
- startedAt: initialResolveStartedAt,
271
- chars: text.count,
272
- mode: repairMode
273
- )
274
-
275
- if repairMode == .replacement {
276
- guard let replacementTarget = initialTarget else {
277
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
278
- return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
279
- }
280
- if currentText == nil || currentText?.isEmpty == false {
281
- let clearStartedAt = Date()
282
- clearTextInput(replacementTarget)
283
- activeTarget = activeTarget.withElement(replacementTarget)
284
- logTextEntryPhase(
285
- commandId: commandId,
286
- phase: "clear",
287
- startedAt: clearStartedAt,
288
- chars: text.count,
289
- mode: repairMode
290
- )
291
- }
292
- }
293
-
294
- func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
295
- if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
296
- currentTarget.typeText(value)
297
- return currentTarget
298
- } else {
299
- app.typeText(value)
300
- return resolveTextEntryElement(app: app, target: activeTarget)
301
- }
302
- }
303
-
304
- func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
305
- guard let expectedValue else {
306
- sleepFor(TextEntryTiming.pollInterval)
307
- return
308
- }
309
- let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
310
- while Date() < deadline {
311
- if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
312
- return
313
- }
314
- sleepFor(TextEntryTiming.pollInterval)
315
- }
316
- }
317
-
318
- let characters = Array(text)
319
- if delaySeconds > 0 && characters.count > 1 {
320
- var typedTarget: XCUIElement?
321
- let delayedTypeStartedAt = Date()
322
- for (index, character) in characters.enumerated() {
323
- typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
324
- if index + 1 < characters.count {
325
- sleepFor(delaySeconds)
326
- }
327
- }
328
- logTextEntryPhase(
329
- commandId: commandId,
330
- phase: "type-delayed",
331
- startedAt: delayedTypeStartedAt,
332
- chars: characters.count,
333
- mode: repairMode
334
- )
335
- if repairMode == .none {
336
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
337
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
338
- }
339
- let verifyStartedAt = Date()
340
- let result = verifyTextEntryWithRepairIfNeeded(
341
- app: app,
342
- target: activeTarget.withElement(typedTarget),
343
- expectedText: expectedText,
344
- repairMode: repairMode
345
- )
346
- logTextEntryPhase(
347
- commandId: commandId,
348
- phase: "verify",
349
- startedAt: verifyStartedAt,
350
- chars: characters.count,
351
- mode: repairMode
352
- )
353
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
354
- return result
355
- }
356
-
357
- let typedTarget: XCUIElement?
358
- if repairMode != .none && characters.count > 1 {
359
- let firstCharacter = String(characters[0])
360
- let firstStartedAt = Date()
361
- var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
362
- logTextEntryPhase(
363
- commandId: commandId,
364
- phase: "type-first",
365
- startedAt: firstStartedAt,
366
- chars: 1,
367
- mode: repairMode
368
- )
369
- activeTarget = activeTarget.withElement(firstTypedTarget)
370
- let warmupExpectedText = expectedTextEntryValue(
371
- typedText: firstCharacter,
372
- mode: repairMode,
373
- initialText: initialText
374
- )
375
- let warmupStartedAt = Date()
376
- waitForWarmupValue(warmupExpectedText, target: activeTarget)
377
- logTextEntryPhase(
378
- commandId: commandId,
379
- phase: "warmup",
380
- startedAt: warmupStartedAt,
381
- chars: 1,
382
- mode: repairMode
383
- )
384
- let remainingText = String(characters.dropFirst())
385
- let remainingStartedAt = Date()
386
- firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
387
- logTextEntryPhase(
388
- commandId: commandId,
389
- phase: "type-remaining",
390
- startedAt: remainingStartedAt,
391
- chars: characters.count - 1,
392
- mode: repairMode
393
- )
394
- typedTarget = firstTypedTarget
395
- } else {
396
- let typeStartedAt = Date()
397
- typedTarget = typeIntoCurrentTarget(text)
398
- logTextEntryPhase(
399
- commandId: commandId,
400
- phase: "type-all",
401
- startedAt: typeStartedAt,
402
- chars: characters.count,
403
- mode: repairMode
404
- )
405
- }
406
- if repairMode == .none {
407
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
408
- return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
409
- }
410
- let verifyStartedAt = Date()
411
- let result = verifyTextEntryWithRepairIfNeeded(
412
- app: app,
413
- target: activeTarget.withElement(typedTarget),
414
- expectedText: expectedText,
415
- repairMode: repairMode
416
- )
417
- logTextEntryPhase(
418
- commandId: commandId,
419
- phase: "verify",
420
- startedAt: verifyStartedAt,
421
- chars: characters.count,
422
- mode: repairMode
423
- )
424
- logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
425
- return result
426
- }
427
-
428
- private func logTextEntryPhase(
429
- commandId: String?,
430
- phase: String,
431
- startedAt: Date,
432
- chars: Int,
433
- mode: TextTypingRepairMode
434
- ) {
435
- NSLog(
436
- "AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=%@ durationMs=%.1f chars=%d mode=%@",
437
- commandId ?? "",
438
- phase,
439
- Date().timeIntervalSince(startedAt) * 1000.0,
440
- chars,
441
- textEntryModeName(mode)
442
- )
443
- }
444
-
445
- func textEntryModeName(_ mode: TextTypingRepairMode) -> String {
446
- switch mode {
447
- case .none:
448
- return "none"
449
- case .append:
450
- return "append"
451
- case .replacement:
452
- return "replacement"
453
- }
454
- }
455
-
456
- private func verifyTextEntryWithRepairIfNeeded(
457
- app: XCUIApplication,
458
- target: TextEntryTarget,
459
- expectedText: String?,
460
- repairMode: TextTypingRepairMode
461
- ) -> TextEntryResult {
462
- let initialResult = verifyTextEntry(
463
- app: app,
464
- target: target,
465
- expectedText: expectedText,
466
- repaired: false
467
- )
468
- #if os(iOS)
469
- guard initialResult.verified == false,
470
- let expectedText = initialResult.expectedText
471
- else {
472
- return initialResult
473
- }
474
- guard shouldRepairTextEntry(
475
- app: app,
476
- target: target,
477
- expectedText: expectedText,
478
- repairMode: repairMode
479
- ) else {
480
- return verifyTextEntry(
481
- app: app,
482
- target: target,
483
- expectedText: expectedText,
484
- repaired: false
485
- )
486
- }
487
-
488
- guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
489
- return initialResult
490
- }
491
- let observedText = editableTextValue(for: repairTarget) ?? ""
492
- NSLog(
493
- "AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
494
- expectedText.count,
495
- observedText.count
496
- )
497
- clearTextInput(repairTarget)
498
- repairTarget.typeText(expectedText)
499
- return verifyTextEntry(
500
- app: app,
501
- target: target.withElement(repairTarget),
502
- expectedText: expectedText,
503
- repaired: true
504
- )
505
- #else
506
- return initialResult
507
- #endif
508
- }
509
-
510
- private func verifyTextEntry(
511
- app: XCUIApplication,
512
- target: TextEntryTarget,
513
- expectedText: String?,
514
- repaired: Bool
515
- ) -> TextEntryResult {
516
- let targetElement = resolveTextEntryElement(app: app, target: target)
517
- guard let expectedText else {
518
- return TextEntryResult(
519
- verified: nil,
520
- repaired: repaired,
521
- expectedText: nil,
522
- observedText: editableTextValue(for: targetElement)
523
- )
524
- }
525
- guard let observedText = editableTextValue(for: targetElement) else {
526
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
527
- }
528
- guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
529
- return TextEntryResult(
530
- verified: false,
531
- repaired: repaired,
532
- expectedText: expectedText,
533
- observedText: observedText
534
- )
535
- }
536
- let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
537
- var latestObservedText = observedText
538
- while Date() < stableDeadline {
539
- sleepFor(TextEntryTiming.pollInterval)
540
- guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
541
- return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
542
- }
543
- latestObservedText = nextObservedText
544
- guard textEntryValueMatchesExpected(
545
- resolveTextEntryElement(app: app, target: target),
546
- observedText: nextObservedText,
547
- expectedText: expectedText
548
- ) else {
549
- return TextEntryResult(
550
- verified: false,
551
- repaired: repaired,
552
- expectedText: expectedText,
553
- observedText: nextObservedText
554
- )
555
- }
556
- }
557
- return TextEntryResult(
558
- verified: true,
559
- repaired: repaired,
560
- expectedText: expectedText,
561
- observedText: latestObservedText
562
- )
563
- }
564
-
565
- private func textEntryValueMatchesExpected(
566
- _ element: XCUIElement?,
567
- observedText: String,
568
- expectedText: String
569
- ) -> Bool {
570
- if observedText == expectedText {
571
- return true
572
- }
573
- guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
574
- return false
575
- }
576
- var submittedText = expectedText
577
- while hasTextEntrySubmitSuffix(submittedText) {
578
- submittedText.removeLast()
579
- }
580
- return observedText == submittedText
581
- }
582
-
583
- private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
584
- text.hasSuffix("\n") || text.hasSuffix("\r")
585
- }
586
-
587
- private func expectedTextEntryValue(
588
- typedText: String,
589
- mode: TextTypingRepairMode,
590
- initialText: String?
591
- ) -> String? {
592
- switch mode {
593
- case .none:
594
- return nil
595
- case .append:
596
- guard let initialText else {
597
- return nil
598
- }
599
- return initialText + typedText
600
- case .replacement:
601
- return typedText
602
- }
603
- }
604
-
605
- private func shouldRepairTextEntry(
606
- app: XCUIApplication,
607
- target: TextEntryTarget,
608
- expectedText: String,
609
- repairMode: TextTypingRepairMode
610
- ) -> Bool {
611
- #if os(iOS)
612
- var latestObservedText: String?
613
- let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
614
- repeat {
615
- guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
616
- return false
617
- }
618
- if textEntryValueMatchesExpected(
619
- resolveTextEntryElement(app: app, target: target),
620
- observedText: observedText,
621
- expectedText: expectedText
622
- ) {
623
- return false
624
- }
625
- latestObservedText = observedText
626
- if !isRepairableTextEntryMismatch(
627
- observedText: observedText,
628
- expectedText: expectedText,
629
- repairMode: repairMode
630
- ) {
631
- return false
632
- }
633
- sleepFor(TextEntryTiming.pollInterval)
634
- } while Date() < deadline
635
-
636
- guard let latestObservedText else {
637
- return false
638
- }
639
- guard !textEntryValueMatchesExpected(
640
- resolveTextEntryElement(app: app, target: target),
641
- observedText: latestObservedText,
642
- expectedText: expectedText
643
- ) else {
644
- return false
645
- }
646
- return isRepairableTextEntryMismatch(
647
- observedText: latestObservedText,
648
- expectedText: expectedText,
649
- repairMode: repairMode
650
- )
651
- #else
652
- return false
653
- #endif
654
- }
655
-
656
- private func isRepairableTextEntryMismatch(
657
- observedText: String,
658
- expectedText: String,
659
- repairMode: TextTypingRepairMode
660
- ) -> Bool {
661
- guard observedText != expectedText else {
662
- return false
663
- }
664
- if repairMode == .replacement {
665
- return true
666
- }
667
- return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
668
- observedText: observedText,
669
- expectedText: expectedText
670
- )
671
- }
672
-
673
- private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
674
- guard observedText.count < expectedText.count else {
675
- return false
676
- }
677
- let missingCharacterCount = expectedText.count - observedText.count
678
- guard missingCharacterCount <= max(2, expectedText.count / 4) else {
679
- return false
680
- }
681
- var expectedIndex = expectedText.startIndex
682
- for character in observedText {
683
- guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
684
- return false
685
- }
686
- expectedIndex = expectedText.index(after: matchIndex)
687
- }
688
- return true
689
- }
690
-
691
- private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
348
+ func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
692
349
  if target.prefersFocusedElement {
693
350
  if let focused = focusedTextInput(app: app) {
694
351
  return focused
@@ -836,7 +493,7 @@ extension RunnerTests {
836
493
  .trimmingCharacters(in: .whitespacesAndNewlines)
837
494
  }
838
495
 
839
- private func editableTextValue(
496
+ func editableTextValue(
840
497
  for element: XCUIElement?,
841
498
  treatingPlaceholderAsEmpty: Bool = false
842
499
  ) -> String? {