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.
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.8.apk → agent-device-android-multitouch-helper-0.16.9.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.9.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.8.manifest.json → agent-device-android-multitouch-helper-0.16.9.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.8.apk → agent-device-android-snapshot-helper-0.16.9.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.9.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.8.manifest.json → agent-device-android-snapshot-helper-0.16.9.manifest.json} +6 -6
- package/dist/src/8114.js +3 -3
- package/dist/src/generic.js +4 -3
- package/dist/src/session.js +2 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -737
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +743 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.8.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.8.apk.sha256 +0 -1
|
@@ -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
|
+
}
|