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