agent-device 0.16.7 → 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.7.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.7.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.7.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.7.manifest.json → agent-device-android-snapshot-helper-0.16.9.manifest.json} +6 -6
- package/dist/src/1352.js +1 -1
- package/dist/src/2415.js +29 -29
- package/dist/src/2805.js +1 -1
- package/dist/src/6232.js +1 -0
- package/dist/src/7455.js +1 -0
- package/dist/src/8114.js +3 -3
- package/dist/src/8699.js +1 -1
- package/dist/src/940.js +1 -1
- package/dist/src/9471.js +1 -1
- package/dist/src/9533.js +1 -1
- package/dist/src/9542.js +1 -1
- package/dist/src/9818.js +1 -1
- package/dist/src/android-adb.d.ts +2 -0
- package/dist/src/android-snapshot-helper.d.ts +2 -0
- package/dist/src/args.js +5 -4
- package/dist/src/cli.js +6 -6
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/find.js +1 -1
- package/dist/src/generic.js +11 -7
- package/dist/src/interaction.js +1 -1
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace.js +3 -3
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/session.js +9 -9
- package/dist/src/snapshot.js +2 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +20 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +141 -774
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -33
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +71 -1
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +80 -10
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +743 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +34 -6
- package/package.json +4 -6
- package/server.json +2 -2
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.7.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.7.apk.sha256 +0 -1
- package/dist/src/5186.js +0 -1
|
@@ -30,48 +30,6 @@ extension RunnerTests {
|
|
|
30
30
|
let usedNonHittableFallback: Bool
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
enum TextTypingRepairMode {
|
|
34
|
-
case none
|
|
35
|
-
case append
|
|
36
|
-
case replacement
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
enum TextEntryTiming {
|
|
40
|
-
static let focusTimeout: TimeInterval = 0.4
|
|
41
|
-
static let repairReadinessTimeout: TimeInterval = 1.0
|
|
42
|
-
static let readinessTimeout: TimeInterval = 2.0
|
|
43
|
-
static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
|
|
44
|
-
static let pollInterval: TimeInterval = 0.02
|
|
45
|
-
static let warmupValueTimeout: TimeInterval = 0.4
|
|
46
|
-
static let verificationStabilityWindow: TimeInterval = 0.2
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
struct TextEntryResult {
|
|
50
|
-
let verified: Bool?
|
|
51
|
-
let repaired: Bool
|
|
52
|
-
let expectedText: String?
|
|
53
|
-
let observedText: String?
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
struct TextEntryTarget {
|
|
57
|
-
let element: XCUIElement?
|
|
58
|
-
let refreshPoint: CGPoint?
|
|
59
|
-
let prefersFocusedElement: Bool
|
|
60
|
-
|
|
61
|
-
func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
|
|
62
|
-
guard let nextElement else {
|
|
63
|
-
return self
|
|
64
|
-
}
|
|
65
|
-
let frame = nextElement.frame
|
|
66
|
-
let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
|
|
67
|
-
return TextEntryTarget(
|
|
68
|
-
element: nextElement,
|
|
69
|
-
refreshPoint: point,
|
|
70
|
-
prefersFocusedElement: prefersFocusedElement
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
33
|
// MARK: - Navigation Gestures
|
|
76
34
|
|
|
77
35
|
func tapInAppBackControl(app: XCUIApplication) -> Bool {
|
|
@@ -283,6 +241,13 @@ extension RunnerTests {
|
|
|
283
241
|
|
|
284
242
|
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
|
|
285
243
|
let point = CGPoint(x: x, y: y)
|
|
244
|
+
let textInputCandidates = textInputCandidatesAt(app: app, point: point)
|
|
245
|
+
for element in textInputCandidates where prefersExpandedTextRead(element) {
|
|
246
|
+
if let text = readableText(for: element) {
|
|
247
|
+
return text
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
286
251
|
let candidates = app.descendants(matching: .any).allElementsBoundByIndex
|
|
287
252
|
.filter { element in
|
|
288
253
|
element.exists && !element.frame.isEmpty && element.frame.contains(point)
|
|
@@ -315,30 +280,29 @@ extension RunnerTests {
|
|
|
315
280
|
return nil
|
|
316
281
|
}
|
|
317
282
|
|
|
318
|
-
func
|
|
319
|
-
|
|
320
|
-
moveCaretToEnd(element: element)
|
|
321
|
-
#endif
|
|
322
|
-
let count = estimatedDeleteCount(for: element)
|
|
323
|
-
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
|
324
|
-
element.typeText(deletes)
|
|
283
|
+
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
|
284
|
+
return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
|
|
325
285
|
}
|
|
326
286
|
|
|
327
|
-
func
|
|
328
|
-
|
|
329
|
-
var matched: XCUIElement?
|
|
287
|
+
private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
|
|
288
|
+
var candidates: [XCUIElement] = []
|
|
330
289
|
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
290
|
+
// Query the text-input element types directly instead of enumerating the entire tree
|
|
291
|
+
// (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
|
|
292
|
+
// slower — it dominated fill latency because resolveTextEntryElement re-runs this on
|
|
293
|
+
// each verify/repair poll once the focused field reference goes stale).
|
|
331
294
|
// Prefer the smallest matching field so nested editable controls win over large containers.
|
|
332
|
-
|
|
295
|
+
candidates = [
|
|
296
|
+
app.textFields,
|
|
297
|
+
app.secureTextFields,
|
|
298
|
+
app.searchFields,
|
|
299
|
+
app.textViews,
|
|
300
|
+
]
|
|
301
|
+
.flatMap { $0.allElementsBoundByIndex }
|
|
333
302
|
.filter { element in
|
|
334
303
|
guard element.exists else { return false }
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
let frame = element.frame
|
|
338
|
-
return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
|
|
339
|
-
default:
|
|
340
|
-
return false
|
|
341
|
-
}
|
|
304
|
+
let frame = element.frame
|
|
305
|
+
return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
|
|
342
306
|
}
|
|
343
307
|
.sorted { left, right in
|
|
344
308
|
let leftArea = max(1, left.frame.width * left.frame.height)
|
|
@@ -354,16 +318,15 @@ extension RunnerTests {
|
|
|
354
318
|
}
|
|
355
319
|
return left.elementType.rawValue < right.elementType.rawValue
|
|
356
320
|
}
|
|
357
|
-
matched = candidates.first
|
|
358
321
|
})
|
|
359
322
|
if let exceptionMessage {
|
|
360
323
|
NSLog(
|
|
361
324
|
"AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
|
|
362
325
|
exceptionMessage
|
|
363
326
|
)
|
|
364
|
-
return
|
|
327
|
+
return []
|
|
365
328
|
}
|
|
366
|
-
return
|
|
329
|
+
return candidates
|
|
367
330
|
}
|
|
368
331
|
|
|
369
332
|
private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
|
|
@@ -373,586 +336,10 @@ extension RunnerTests {
|
|
|
373
336
|
&& point.y <= frame.maxY + tolerance
|
|
374
337
|
}
|
|
375
338
|
|
|
376
|
-
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
|
|
377
|
-
#if os(iOS)
|
|
378
|
-
// iOS focus predicates can return stale or misleading text-input matches
|
|
379
|
-
// under XCUITest, so text entry readiness is driven by tap/keyboard state.
|
|
380
|
-
return nil
|
|
381
|
-
#else
|
|
382
|
-
var focused: XCUIElement?
|
|
383
|
-
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
384
|
-
let candidates = app
|
|
385
|
-
.descendants(matching: .any)
|
|
386
|
-
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
|
|
387
|
-
.allElementsBoundByIndex
|
|
388
|
-
for candidate in candidates where candidate.exists {
|
|
389
|
-
switch candidate.elementType {
|
|
390
|
-
case .textField, .secureTextField, .searchField, .textView:
|
|
391
|
-
focused = candidate
|
|
392
|
-
return
|
|
393
|
-
default:
|
|
394
|
-
continue
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
})
|
|
398
|
-
if let exceptionMessage {
|
|
399
|
-
NSLog(
|
|
400
|
-
"AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
|
|
401
|
-
exceptionMessage
|
|
402
|
-
)
|
|
403
|
-
return nil
|
|
404
|
-
}
|
|
405
|
-
return focused
|
|
406
|
-
#endif
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
|
|
410
|
-
#if os(tvOS)
|
|
411
|
-
return target
|
|
412
|
-
#else
|
|
413
|
-
let latest = target
|
|
414
|
-
let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
|
|
415
|
-
while Date() < deadline {
|
|
416
|
-
if let focused = focusedTextInput(app: app) {
|
|
417
|
-
return focused
|
|
418
|
-
}
|
|
419
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
420
|
-
}
|
|
421
|
-
return latest
|
|
422
|
-
#endif
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
|
|
426
|
-
guard let x, let y else {
|
|
427
|
-
let focused = waitForTextEntryReadiness(
|
|
428
|
-
app: app,
|
|
429
|
-
target: TextEntryTarget(
|
|
430
|
-
element: focusedTextInput(app: app),
|
|
431
|
-
refreshPoint: nil,
|
|
432
|
-
prefersFocusedElement: true
|
|
433
|
-
)
|
|
434
|
-
)
|
|
435
|
-
return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
let target = textInputAt(app: app, x: x, y: y)
|
|
439
|
-
let requestedPoint = CGPoint(x: x, y: y)
|
|
440
|
-
if let target {
|
|
441
|
-
let frame = target.frame
|
|
442
|
-
if !frame.isEmpty {
|
|
443
|
-
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
|
|
444
|
-
} else {
|
|
445
|
-
_ = tapAt(app: app, x: x, y: y)
|
|
446
|
-
}
|
|
447
|
-
} else {
|
|
448
|
-
_ = tapAt(app: app, x: x, y: y)
|
|
449
|
-
}
|
|
450
|
-
let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
|
|
451
|
-
let element = waitForTextEntryReadiness(
|
|
452
|
-
app: app,
|
|
453
|
-
target: TextEntryTarget(
|
|
454
|
-
element: stabilized ?? target,
|
|
455
|
-
refreshPoint: requestedPoint,
|
|
456
|
-
prefersFocusedElement: false
|
|
457
|
-
)
|
|
458
|
-
) ?? stabilized ?? target
|
|
459
|
-
return TextEntryTarget(
|
|
460
|
-
element: element,
|
|
461
|
-
refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
|
|
462
|
-
prefersFocusedElement: false
|
|
463
|
-
)
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
|
|
467
|
-
let point = textEntryRefreshPoint(for: element)
|
|
468
|
-
if let point {
|
|
469
|
-
_ = tapAt(app: app, x: point.x, y: point.y)
|
|
470
|
-
}
|
|
471
|
-
let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
|
|
472
|
-
let resolved = waitForTextEntryReadiness(
|
|
473
|
-
app: app,
|
|
474
|
-
target: TextEntryTarget(
|
|
475
|
-
element: stabilized ?? element,
|
|
476
|
-
refreshPoint: point,
|
|
477
|
-
prefersFocusedElement: false
|
|
478
|
-
)
|
|
479
|
-
) ?? stabilized ?? element
|
|
480
|
-
return TextEntryTarget(
|
|
481
|
-
element: resolved,
|
|
482
|
-
refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
|
|
483
|
-
prefersFocusedElement: false
|
|
484
|
-
)
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
func isTextEntryElement(_ element: XCUIElement) -> Bool {
|
|
488
|
-
switch element.elementType {
|
|
489
|
-
case .textField, .secureTextField, .searchField, .textView:
|
|
490
|
-
return true
|
|
491
|
-
default:
|
|
492
|
-
return false
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
|
|
497
|
-
switch command.textEntryMode {
|
|
498
|
-
case "append":
|
|
499
|
-
return .append
|
|
500
|
-
case "replace":
|
|
501
|
-
return .replacement
|
|
502
|
-
default:
|
|
503
|
-
return command.clearFirst == true ? .replacement : .none
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
func typeTextReliably(
|
|
508
|
-
app: XCUIApplication,
|
|
509
|
-
target: TextEntryTarget,
|
|
510
|
-
text: String,
|
|
511
|
-
delaySeconds: Double,
|
|
512
|
-
repairMode: TextTypingRepairMode = .none
|
|
513
|
-
) -> TextEntryResult {
|
|
514
|
-
guard !text.isEmpty else {
|
|
515
|
-
return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
|
|
516
|
-
}
|
|
517
|
-
var activeTarget = target
|
|
518
|
-
let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
|
|
519
|
-
activeTarget = activeTarget.withElement(initialTarget)
|
|
520
|
-
let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
|
|
521
|
-
let initialText = repairMode == .append ? currentText : nil
|
|
522
|
-
let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
|
|
523
|
-
|
|
524
|
-
if repairMode == .replacement {
|
|
525
|
-
guard let replacementTarget = initialTarget else {
|
|
526
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
527
|
-
}
|
|
528
|
-
if currentText == nil || currentText?.isEmpty == false {
|
|
529
|
-
clearTextInput(replacementTarget)
|
|
530
|
-
activeTarget = activeTarget.withElement(replacementTarget)
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
|
|
535
|
-
if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
|
|
536
|
-
app.typeText(value)
|
|
537
|
-
return currentTarget
|
|
538
|
-
} else {
|
|
539
|
-
app.typeText(value)
|
|
540
|
-
return resolveTextEntryElement(app: app, target: activeTarget)
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
|
|
545
|
-
guard let expectedValue else {
|
|
546
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
547
|
-
return
|
|
548
|
-
}
|
|
549
|
-
let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
|
|
550
|
-
while Date() < deadline {
|
|
551
|
-
if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
|
|
552
|
-
return
|
|
553
|
-
}
|
|
554
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
let characters = Array(text)
|
|
559
|
-
if delaySeconds > 0 && characters.count > 1 {
|
|
560
|
-
var typedTarget: XCUIElement?
|
|
561
|
-
for (index, character) in characters.enumerated() {
|
|
562
|
-
typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
|
|
563
|
-
if index + 1 < characters.count {
|
|
564
|
-
sleepFor(delaySeconds)
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
if repairMode == .none {
|
|
568
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
|
|
569
|
-
}
|
|
570
|
-
let repairResult = repairTextEntryIfNeeded(
|
|
571
|
-
app: app,
|
|
572
|
-
target: activeTarget.withElement(typedTarget),
|
|
573
|
-
expectedText: expectedText,
|
|
574
|
-
repairMode: repairMode
|
|
575
|
-
)
|
|
576
|
-
return verifyTextEntry(
|
|
577
|
-
app: app,
|
|
578
|
-
target: activeTarget.withElement(typedTarget),
|
|
579
|
-
expectedText: expectedText,
|
|
580
|
-
repaired: repairResult.repaired
|
|
581
|
-
)
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
let typedTarget: XCUIElement?
|
|
585
|
-
if repairMode != .none && characters.count > 1 {
|
|
586
|
-
let firstCharacter = String(characters[0])
|
|
587
|
-
var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
|
|
588
|
-
activeTarget = activeTarget.withElement(firstTypedTarget)
|
|
589
|
-
let warmupExpectedText = expectedTextEntryValue(
|
|
590
|
-
typedText: firstCharacter,
|
|
591
|
-
mode: repairMode,
|
|
592
|
-
initialText: initialText
|
|
593
|
-
)
|
|
594
|
-
waitForWarmupValue(warmupExpectedText, target: activeTarget)
|
|
595
|
-
let remainingText = String(characters.dropFirst())
|
|
596
|
-
firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
|
|
597
|
-
typedTarget = firstTypedTarget
|
|
598
|
-
} else {
|
|
599
|
-
typedTarget = typeIntoCurrentTarget(text)
|
|
600
|
-
}
|
|
601
|
-
if repairMode == .none {
|
|
602
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
|
|
603
|
-
}
|
|
604
|
-
let repairResult = repairTextEntryIfNeeded(
|
|
605
|
-
app: app,
|
|
606
|
-
target: activeTarget.withElement(typedTarget),
|
|
607
|
-
expectedText: expectedText,
|
|
608
|
-
repairMode: repairMode
|
|
609
|
-
)
|
|
610
|
-
return verifyTextEntry(
|
|
611
|
-
app: app,
|
|
612
|
-
target: activeTarget.withElement(typedTarget),
|
|
613
|
-
expectedText: expectedText,
|
|
614
|
-
repaired: repairResult.repaired
|
|
615
|
-
)
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
private func repairTextEntryIfNeeded(
|
|
619
|
-
app: XCUIApplication,
|
|
620
|
-
target: TextEntryTarget,
|
|
621
|
-
expectedText: String?,
|
|
622
|
-
repairMode: TextTypingRepairMode
|
|
623
|
-
) -> TextEntryResult {
|
|
624
|
-
#if os(iOS)
|
|
625
|
-
guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
|
|
626
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
627
|
-
}
|
|
628
|
-
guard let expectedText else {
|
|
629
|
-
let observedText = editableTextValue(for: targetElement)
|
|
630
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
|
|
631
|
-
}
|
|
632
|
-
guard shouldRepairTextEntry(
|
|
633
|
-
app: app,
|
|
634
|
-
target: target,
|
|
635
|
-
expectedText: expectedText,
|
|
636
|
-
repairMode: repairMode
|
|
637
|
-
) else {
|
|
638
|
-
return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
|
|
642
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
643
|
-
}
|
|
644
|
-
let observedText = editableTextValue(for: repairTarget) ?? ""
|
|
645
|
-
NSLog(
|
|
646
|
-
"AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
|
|
647
|
-
expectedText.count,
|
|
648
|
-
observedText.count
|
|
649
|
-
)
|
|
650
|
-
clearTextInput(repairTarget)
|
|
651
|
-
app.typeText(expectedText)
|
|
652
|
-
return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
|
|
653
|
-
#else
|
|
654
|
-
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
655
|
-
#endif
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
private func verifyTextEntry(
|
|
659
|
-
app: XCUIApplication,
|
|
660
|
-
target: TextEntryTarget,
|
|
661
|
-
expectedText: String?,
|
|
662
|
-
repaired: Bool
|
|
663
|
-
) -> TextEntryResult {
|
|
664
|
-
let targetElement = resolveTextEntryElement(app: app, target: target)
|
|
665
|
-
guard let expectedText else {
|
|
666
|
-
return TextEntryResult(
|
|
667
|
-
verified: nil,
|
|
668
|
-
repaired: repaired,
|
|
669
|
-
expectedText: nil,
|
|
670
|
-
observedText: editableTextValue(for: targetElement)
|
|
671
|
-
)
|
|
672
|
-
}
|
|
673
|
-
guard let observedText = editableTextValue(for: targetElement) else {
|
|
674
|
-
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
|
|
675
|
-
}
|
|
676
|
-
guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
|
|
677
|
-
return TextEntryResult(
|
|
678
|
-
verified: false,
|
|
679
|
-
repaired: repaired,
|
|
680
|
-
expectedText: expectedText,
|
|
681
|
-
observedText: observedText
|
|
682
|
-
)
|
|
683
|
-
}
|
|
684
|
-
let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
|
|
685
|
-
var latestObservedText = observedText
|
|
686
|
-
while Date() < stableDeadline {
|
|
687
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
688
|
-
guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
|
|
689
|
-
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
|
|
690
|
-
}
|
|
691
|
-
latestObservedText = nextObservedText
|
|
692
|
-
guard textEntryValueMatchesExpected(
|
|
693
|
-
resolveTextEntryElement(app: app, target: target),
|
|
694
|
-
observedText: nextObservedText,
|
|
695
|
-
expectedText: expectedText
|
|
696
|
-
) else {
|
|
697
|
-
return TextEntryResult(
|
|
698
|
-
verified: false,
|
|
699
|
-
repaired: repaired,
|
|
700
|
-
expectedText: expectedText,
|
|
701
|
-
observedText: nextObservedText
|
|
702
|
-
)
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
return TextEntryResult(
|
|
706
|
-
verified: true,
|
|
707
|
-
repaired: repaired,
|
|
708
|
-
expectedText: expectedText,
|
|
709
|
-
observedText: latestObservedText
|
|
710
|
-
)
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
private func textEntryValueMatchesExpected(
|
|
714
|
-
_ element: XCUIElement?,
|
|
715
|
-
observedText: String,
|
|
716
|
-
expectedText: String
|
|
717
|
-
) -> Bool {
|
|
718
|
-
if observedText == expectedText {
|
|
719
|
-
return true
|
|
720
|
-
}
|
|
721
|
-
guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
|
|
722
|
-
return false
|
|
723
|
-
}
|
|
724
|
-
var submittedText = expectedText
|
|
725
|
-
while hasTextEntrySubmitSuffix(submittedText) {
|
|
726
|
-
submittedText.removeLast()
|
|
727
|
-
}
|
|
728
|
-
return observedText == submittedText
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
|
|
732
|
-
text.hasSuffix("\n") || text.hasSuffix("\r")
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
private func expectedTextEntryValue(
|
|
736
|
-
typedText: String,
|
|
737
|
-
mode: TextTypingRepairMode,
|
|
738
|
-
initialText: String?
|
|
739
|
-
) -> String? {
|
|
740
|
-
switch mode {
|
|
741
|
-
case .none:
|
|
742
|
-
return nil
|
|
743
|
-
case .append:
|
|
744
|
-
guard let initialText else {
|
|
745
|
-
return nil
|
|
746
|
-
}
|
|
747
|
-
return initialText + typedText
|
|
748
|
-
case .replacement:
|
|
749
|
-
return typedText
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
private func shouldRepairTextEntry(
|
|
754
|
-
app: XCUIApplication,
|
|
755
|
-
target: TextEntryTarget,
|
|
756
|
-
expectedText: String,
|
|
757
|
-
repairMode: TextTypingRepairMode
|
|
758
|
-
) -> Bool {
|
|
759
|
-
#if os(iOS)
|
|
760
|
-
var latestObservedText: String?
|
|
761
|
-
let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
|
|
762
|
-
repeat {
|
|
763
|
-
guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
|
|
764
|
-
return false
|
|
765
|
-
}
|
|
766
|
-
if textEntryValueMatchesExpected(
|
|
767
|
-
resolveTextEntryElement(app: app, target: target),
|
|
768
|
-
observedText: observedText,
|
|
769
|
-
expectedText: expectedText
|
|
770
|
-
) {
|
|
771
|
-
return false
|
|
772
|
-
}
|
|
773
|
-
latestObservedText = observedText
|
|
774
|
-
if !isRepairableTextEntryMismatch(
|
|
775
|
-
observedText: observedText,
|
|
776
|
-
expectedText: expectedText,
|
|
777
|
-
repairMode: repairMode
|
|
778
|
-
) {
|
|
779
|
-
return false
|
|
780
|
-
}
|
|
781
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
782
|
-
} while Date() < deadline
|
|
783
|
-
|
|
784
|
-
guard let latestObservedText else {
|
|
785
|
-
return false
|
|
786
|
-
}
|
|
787
|
-
guard !textEntryValueMatchesExpected(
|
|
788
|
-
resolveTextEntryElement(app: app, target: target),
|
|
789
|
-
observedText: latestObservedText,
|
|
790
|
-
expectedText: expectedText
|
|
791
|
-
) else {
|
|
792
|
-
return false
|
|
793
|
-
}
|
|
794
|
-
return isRepairableTextEntryMismatch(
|
|
795
|
-
observedText: latestObservedText,
|
|
796
|
-
expectedText: expectedText,
|
|
797
|
-
repairMode: repairMode
|
|
798
|
-
)
|
|
799
|
-
#else
|
|
800
|
-
return false
|
|
801
|
-
#endif
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
private func isRepairableTextEntryMismatch(
|
|
805
|
-
observedText: String,
|
|
806
|
-
expectedText: String,
|
|
807
|
-
repairMode: TextTypingRepairMode
|
|
808
|
-
) -> Bool {
|
|
809
|
-
guard observedText != expectedText else {
|
|
810
|
-
return false
|
|
811
|
-
}
|
|
812
|
-
if repairMode == .replacement {
|
|
813
|
-
return true
|
|
814
|
-
}
|
|
815
|
-
return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
|
|
816
|
-
observedText: observedText,
|
|
817
|
-
expectedText: expectedText
|
|
818
|
-
)
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
|
|
822
|
-
guard observedText.count < expectedText.count else {
|
|
823
|
-
return false
|
|
824
|
-
}
|
|
825
|
-
let missingCharacterCount = expectedText.count - observedText.count
|
|
826
|
-
guard missingCharacterCount <= max(2, expectedText.count / 4) else {
|
|
827
|
-
return false
|
|
828
|
-
}
|
|
829
|
-
var expectedIndex = expectedText.startIndex
|
|
830
|
-
for character in observedText {
|
|
831
|
-
guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
|
|
832
|
-
return false
|
|
833
|
-
}
|
|
834
|
-
expectedIndex = expectedText.index(after: matchIndex)
|
|
835
|
-
}
|
|
836
|
-
return true
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
|
|
840
|
-
if target.prefersFocusedElement {
|
|
841
|
-
if let focused = focusedTextInput(app: app) {
|
|
842
|
-
return focused
|
|
843
|
-
}
|
|
844
|
-
if let element = target.element, element.exists {
|
|
845
|
-
return element
|
|
846
|
-
}
|
|
847
|
-
} else {
|
|
848
|
-
if let element = target.element, element.exists {
|
|
849
|
-
return element
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
if let refreshPoint = target.refreshPoint,
|
|
853
|
-
let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
|
|
854
|
-
return refreshed
|
|
855
|
-
}
|
|
856
|
-
if let focused = focusedTextInput(app: app) {
|
|
857
|
-
return focused
|
|
858
|
-
}
|
|
859
|
-
return nil
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
private func waitForTextEntryReadiness(
|
|
863
|
-
app: XCUIApplication,
|
|
864
|
-
target: TextEntryTarget,
|
|
865
|
-
timeout: TimeInterval = TextEntryTiming.readinessTimeout
|
|
866
|
-
) -> XCUIElement? {
|
|
867
|
-
#if os(iOS)
|
|
868
|
-
var latest = resolveTextEntryElement(app: app, target: target)
|
|
869
|
-
let deadline = Date().addingTimeInterval(timeout)
|
|
870
|
-
let hardwareKeyboardFallback = Date().addingTimeInterval(
|
|
871
|
-
min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
|
|
872
|
-
)
|
|
873
|
-
var sawSoftwareKeyboard = false
|
|
874
|
-
while Date() < deadline {
|
|
875
|
-
if let focused = focusedTextInput(app: app) {
|
|
876
|
-
latest = focused
|
|
877
|
-
if isKeyboardVisible(app: app) {
|
|
878
|
-
return focused
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
|
|
882
|
-
if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
|
|
883
|
-
return latest
|
|
884
|
-
}
|
|
885
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
886
|
-
}
|
|
887
|
-
return focusedTextInput(app: app) ?? latest
|
|
888
|
-
#else
|
|
889
|
-
return resolveTextEntryElement(app: app, target: target)
|
|
890
|
-
#endif
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {
|
|
894
|
-
#if os(iOS)
|
|
895
|
-
switch element.elementType {
|
|
896
|
-
case .textField, .secureTextField, .searchField, .textView:
|
|
897
|
-
if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil {
|
|
898
|
-
return
|
|
899
|
-
}
|
|
900
|
-
let frame = element.frame
|
|
901
|
-
if !frame.isEmpty {
|
|
902
|
-
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
|
|
903
|
-
_ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout)
|
|
904
|
-
}
|
|
905
|
-
default:
|
|
906
|
-
return
|
|
907
|
-
}
|
|
908
|
-
#endif
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
|
|
912
|
-
let deadline = Date().addingTimeInterval(timeout)
|
|
913
|
-
while Date() < deadline {
|
|
914
|
-
if let focused = focusedTextInput(app: app) {
|
|
915
|
-
return focused
|
|
916
|
-
}
|
|
917
|
-
sleepFor(TextEntryTiming.pollInterval)
|
|
918
|
-
}
|
|
919
|
-
return focusedTextInput(app: app)
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
|
|
923
|
-
guard let element else {
|
|
924
|
-
return nil
|
|
925
|
-
}
|
|
926
|
-
let frame = element.frame
|
|
927
|
-
guard !frame.isEmpty else {
|
|
928
|
-
return nil
|
|
929
|
-
}
|
|
930
|
-
return CGPoint(x: frame.midX, y: frame.midY)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
339
|
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
|
934
340
|
return visibleKeyboardFrame(app: app) != nil
|
|
935
341
|
}
|
|
936
342
|
|
|
937
|
-
private func keyboardElementExists(app: XCUIApplication) -> Bool {
|
|
938
|
-
#if os(iOS)
|
|
939
|
-
var exists = false
|
|
940
|
-
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
941
|
-
exists = app.keyboards.firstMatch.exists
|
|
942
|
-
})
|
|
943
|
-
if let exceptionMessage {
|
|
944
|
-
NSLog(
|
|
945
|
-
"AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
|
|
946
|
-
exceptionMessage
|
|
947
|
-
)
|
|
948
|
-
return false
|
|
949
|
-
}
|
|
950
|
-
return exists
|
|
951
|
-
#else
|
|
952
|
-
return false
|
|
953
|
-
#endif
|
|
954
|
-
}
|
|
955
|
-
|
|
956
343
|
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
957
344
|
let wasVisible = isKeyboardVisible(app: app)
|
|
958
345
|
guard wasVisible else {
|
|
@@ -978,6 +365,14 @@ extension RunnerTests {
|
|
|
978
365
|
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
979
366
|
}
|
|
980
367
|
|
|
368
|
+
if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) {
|
|
369
|
+
sleepFor(0.2)
|
|
370
|
+
let visible = isKeyboardVisible(app: app)
|
|
371
|
+
if !visible {
|
|
372
|
+
return (wasVisible: true, dismissed: true, visible: false)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
981
376
|
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
|
982
377
|
#endif
|
|
983
378
|
}
|
|
@@ -1098,7 +493,10 @@ extension RunnerTests {
|
|
|
1098
493
|
#endif
|
|
1099
494
|
}
|
|
1100
495
|
|
|
1101
|
-
private func tapKeyboardReturnControl(
|
|
496
|
+
private func tapKeyboardReturnControl(
|
|
497
|
+
app: XCUIApplication,
|
|
498
|
+
allowCoordinateFallback: Bool = false
|
|
499
|
+
) -> Bool {
|
|
1102
500
|
#if os(iOS)
|
|
1103
501
|
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
|
|
1104
502
|
let candidates = [
|
|
@@ -1109,6 +507,21 @@ extension RunnerTests {
|
|
|
1109
507
|
hittable.tap()
|
|
1110
508
|
return true
|
|
1111
509
|
}
|
|
510
|
+
if allowCoordinateFallback,
|
|
511
|
+
let keyboardFrame = visibleKeyboardFrame(app: app),
|
|
512
|
+
let framed = candidates.first(where: {
|
|
513
|
+
guard $0.exists else { return false }
|
|
514
|
+
let frame = $0.frame
|
|
515
|
+
return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
|
|
516
|
+
}) {
|
|
517
|
+
let frame = framed.frame
|
|
518
|
+
switch tapAt(app: app, x: frame.midX, y: frame.midY) {
|
|
519
|
+
case .performed:
|
|
520
|
+
return true
|
|
521
|
+
case .unsupported:
|
|
522
|
+
return false
|
|
523
|
+
}
|
|
524
|
+
}
|
|
1112
525
|
}
|
|
1113
526
|
#endif
|
|
1114
527
|
return false
|
|
@@ -1122,80 +535,6 @@ extension RunnerTests {
|
|
|
1122
535
|
return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
|
|
1123
536
|
}
|
|
1124
537
|
|
|
1125
|
-
private func moveCaretToEnd(element: XCUIElement) {
|
|
1126
|
-
#if os(tvOS)
|
|
1127
|
-
return
|
|
1128
|
-
#else
|
|
1129
|
-
let frame = element.frame
|
|
1130
|
-
guard !frame.isEmpty else {
|
|
1131
|
-
element.tap()
|
|
1132
|
-
return
|
|
1133
|
-
}
|
|
1134
|
-
let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
1135
|
-
let target = origin.withOffset(
|
|
1136
|
-
CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
|
|
1137
|
-
)
|
|
1138
|
-
target.tap()
|
|
1139
|
-
#endif
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
private func estimatedDeleteCount(for element: XCUIElement) -> Int {
|
|
1143
|
-
let valueText = normalizedElementText(element.value)
|
|
1144
|
-
let base = valueText.isEmpty ? 24 : (valueText.count + 8)
|
|
1145
|
-
return max(24, min(120, base))
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
private func normalizedElementText(_ value: Any?) -> String {
|
|
1149
|
-
String(describing: value ?? "")
|
|
1150
|
-
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
private func editableTextValue(
|
|
1154
|
-
for element: XCUIElement?,
|
|
1155
|
-
treatingPlaceholderAsEmpty: Bool = false
|
|
1156
|
-
) -> String? {
|
|
1157
|
-
guard let element else {
|
|
1158
|
-
return nil
|
|
1159
|
-
}
|
|
1160
|
-
switch element.elementType {
|
|
1161
|
-
case .textField, .searchField, .textView:
|
|
1162
|
-
let value = String(describing: element.value ?? "")
|
|
1163
|
-
if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
|
|
1164
|
-
return ""
|
|
1165
|
-
}
|
|
1166
|
-
return value
|
|
1167
|
-
case .secureTextField:
|
|
1168
|
-
return nil
|
|
1169
|
-
default:
|
|
1170
|
-
return nil
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
|
|
1175
|
-
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1176
|
-
guard !normalizedValue.isEmpty else {
|
|
1177
|
-
return false
|
|
1178
|
-
}
|
|
1179
|
-
let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
1180
|
-
if !placeholder.isEmpty && normalizedValue == placeholder {
|
|
1181
|
-
return true
|
|
1182
|
-
}
|
|
1183
|
-
if isGenericTextInputLabel(normalizedValue) {
|
|
1184
|
-
return true
|
|
1185
|
-
}
|
|
1186
|
-
let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1187
|
-
return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
private func isGenericTextInputLabel(_ value: String) -> Bool {
|
|
1191
|
-
switch value {
|
|
1192
|
-
case "Text input field":
|
|
1193
|
-
return true
|
|
1194
|
-
default:
|
|
1195
|
-
return false
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
538
|
private func readableText(for element: XCUIElement) -> String? {
|
|
1200
539
|
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1201
540
|
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
@@ -1510,11 +849,69 @@ extension RunnerTests {
|
|
|
1510
849
|
}
|
|
1511
850
|
|
|
1512
851
|
func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
|
|
1513
|
-
|
|
852
|
+
#if os(iOS)
|
|
853
|
+
// A coordinate tap+drag is a single-finger gesture: React Native reads it as a pan
|
|
854
|
+
// and the pinch scale never changes (#629). Drive the two-finger XCTest synthesis
|
|
855
|
+
// path (the same one transformGesture uses) with zero translation/rotation so RN's
|
|
856
|
+
// pinch recognizer actually fires.
|
|
857
|
+
let frame = interactionRoot(app: app).frame
|
|
858
|
+
let centerX = x ?? Double(frame.midX)
|
|
859
|
+
let centerY = y ?? Double(frame.midY)
|
|
860
|
+
return transformGesture(
|
|
861
|
+
app: app,
|
|
862
|
+
x: centerX,
|
|
863
|
+
y: centerY,
|
|
864
|
+
dx: 0,
|
|
865
|
+
dy: 0,
|
|
866
|
+
scale: scale,
|
|
867
|
+
degrees: 0,
|
|
868
|
+
durationMs: 300
|
|
869
|
+
)
|
|
870
|
+
#elseif os(tvOS)
|
|
871
|
+
return .unsupported(
|
|
872
|
+
message: "pinch is not supported on tvOS",
|
|
873
|
+
hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
|
|
874
|
+
)
|
|
875
|
+
#else
|
|
876
|
+
return .unsupported(
|
|
877
|
+
message: "pinch is not supported on macOS",
|
|
878
|
+
hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
|
|
879
|
+
)
|
|
880
|
+
#endif
|
|
1514
881
|
}
|
|
1515
882
|
|
|
1516
883
|
func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
|
|
1517
|
-
|
|
884
|
+
#if os(iOS)
|
|
885
|
+
// Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
|
|
886
|
+
// with zero translation/scale so React Native's rotation recognizer actually fires. The native
|
|
887
|
+
// XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
|
|
888
|
+
// handler does not read reliably — the same class of problem #629/#634 fixed for pinch.
|
|
889
|
+
// velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
|
|
890
|
+
// keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
|
|
891
|
+
let frame = interactionRoot(app: app).frame
|
|
892
|
+
let centerX = x ?? Double(frame.midX)
|
|
893
|
+
let centerY = y ?? Double(frame.midY)
|
|
894
|
+
return transformGesture(
|
|
895
|
+
app: app,
|
|
896
|
+
x: centerX,
|
|
897
|
+
y: centerY,
|
|
898
|
+
dx: 0,
|
|
899
|
+
dy: 0,
|
|
900
|
+
scale: 1,
|
|
901
|
+
degrees: degrees,
|
|
902
|
+
durationMs: 300
|
|
903
|
+
)
|
|
904
|
+
#elseif os(tvOS)
|
|
905
|
+
return .unsupported(
|
|
906
|
+
message: "rotate-gesture is not supported on tvOS",
|
|
907
|
+
hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
|
|
908
|
+
)
|
|
909
|
+
#else
|
|
910
|
+
return .unsupported(
|
|
911
|
+
message: "rotate-gesture is not supported on macOS",
|
|
912
|
+
hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
|
|
913
|
+
)
|
|
914
|
+
#endif
|
|
1518
915
|
}
|
|
1519
916
|
|
|
1520
917
|
func transformGesture(
|
|
@@ -1540,13 +937,22 @@ extension RunnerTests {
|
|
|
1540
937
|
radius: transformGestureRadius(frame: target.frame, scale: scale),
|
|
1541
938
|
durationMs: durationMs
|
|
1542
939
|
) {
|
|
1543
|
-
return .unsupported(
|
|
940
|
+
return .unsupported(
|
|
941
|
+
message: message,
|
|
942
|
+
hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
|
|
943
|
+
)
|
|
1544
944
|
}
|
|
1545
945
|
return .performed
|
|
1546
946
|
#elseif os(tvOS)
|
|
1547
|
-
return .unsupported(
|
|
947
|
+
return .unsupported(
|
|
948
|
+
message: "transformGesture is not supported on tvOS",
|
|
949
|
+
hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
|
|
950
|
+
)
|
|
1548
951
|
#else
|
|
1549
|
-
return .unsupported(
|
|
952
|
+
return .unsupported(
|
|
953
|
+
message: "transformGesture is not supported on macOS",
|
|
954
|
+
hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
|
|
955
|
+
)
|
|
1550
956
|
#endif
|
|
1551
957
|
}
|
|
1552
958
|
|
|
@@ -1558,57 +964,6 @@ extension RunnerTests {
|
|
|
1558
964
|
return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
|
|
1559
965
|
}
|
|
1560
966
|
|
|
1561
|
-
private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
|
|
1562
|
-
#if os(tvOS)
|
|
1563
|
-
return .unsupported("pinch is not supported on tvOS")
|
|
1564
|
-
#else
|
|
1565
|
-
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
1566
|
-
|
|
1567
|
-
// Use double-tap + drag gesture for reliable map zoom
|
|
1568
|
-
// Zoom in (scale > 1): tap then drag UP
|
|
1569
|
-
// Zoom out (scale < 1): tap then drag DOWN
|
|
1570
|
-
|
|
1571
|
-
// Determine center point (use provided x/y or screen center)
|
|
1572
|
-
let centerX = x.map { $0 / target.frame.width } ?? 0.5
|
|
1573
|
-
let centerY = y.map { $0 / target.frame.height } ?? 0.5
|
|
1574
|
-
let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))
|
|
1575
|
-
|
|
1576
|
-
// Calculate drag distance based on scale (clamped to reasonable range)
|
|
1577
|
-
// Larger scale = more drag distance
|
|
1578
|
-
let dragAmount: CGFloat
|
|
1579
|
-
if scale > 1.0 {
|
|
1580
|
-
// Zoom in: drag up (negative Y direction in normalized coords)
|
|
1581
|
-
dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
|
|
1582
|
-
} else {
|
|
1583
|
-
// Zoom out: drag down (positive Y direction)
|
|
1584
|
-
dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
|
|
1588
|
-
let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))
|
|
1589
|
-
|
|
1590
|
-
// Tap first (first tap of double-tap)
|
|
1591
|
-
center.tap()
|
|
1592
|
-
|
|
1593
|
-
// Immediately press and drag (second tap + drag)
|
|
1594
|
-
center.press(forDuration: 0.05, thenDragTo: endPoint)
|
|
1595
|
-
return .performed
|
|
1596
|
-
#endif
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
private func performCoordinateRotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
|
|
1600
|
-
#if os(iOS)
|
|
1601
|
-
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
1602
|
-
let radians = CGFloat(degrees * .pi / 180.0)
|
|
1603
|
-
target.rotate(radians, withVelocity: CGFloat(velocity))
|
|
1604
|
-
return .performed
|
|
1605
|
-
#elseif os(tvOS)
|
|
1606
|
-
return .unsupported("rotate-gesture is not supported on tvOS")
|
|
1607
|
-
#else
|
|
1608
|
-
return .unsupported("rotate-gesture is not supported on macOS")
|
|
1609
|
-
#endif
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
967
|
private func interactionRoot(app: XCUIApplication) -> XCUIElement {
|
|
1613
968
|
let windows = app.windows.allElementsBoundByIndex
|
|
1614
969
|
if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
|
|
@@ -1619,7 +974,10 @@ extension RunnerTests {
|
|
|
1619
974
|
|
|
1620
975
|
private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
1621
976
|
#if os(tvOS)
|
|
1622
|
-
return .unsupported(
|
|
977
|
+
return .unsupported(
|
|
978
|
+
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
979
|
+
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
980
|
+
)
|
|
1623
981
|
#else
|
|
1624
982
|
interactionCoordinate(app: app, x: x, y: y).tap()
|
|
1625
983
|
return .performed
|
|
@@ -1628,7 +986,10 @@ extension RunnerTests {
|
|
|
1628
986
|
|
|
1629
987
|
private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
1630
988
|
#if os(tvOS)
|
|
1631
|
-
return .unsupported(
|
|
989
|
+
return .unsupported(
|
|
990
|
+
message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
991
|
+
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
992
|
+
)
|
|
1632
993
|
#else
|
|
1633
994
|
interactionCoordinate(app: app, x: x, y: y).doubleTap()
|
|
1634
995
|
return .performed
|
|
@@ -1637,7 +998,10 @@ extension RunnerTests {
|
|
|
1637
998
|
|
|
1638
999
|
private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
|
|
1639
1000
|
#if os(tvOS)
|
|
1640
|
-
return .unsupported(
|
|
1001
|
+
return .unsupported(
|
|
1002
|
+
message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
|
|
1003
|
+
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
|
|
1004
|
+
)
|
|
1641
1005
|
#else
|
|
1642
1006
|
interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
|
|
1643
1007
|
return .performed
|
|
@@ -1653,7 +1017,10 @@ extension RunnerTests {
|
|
|
1653
1017
|
holdDuration: TimeInterval
|
|
1654
1018
|
) -> RunnerInteractionOutcome {
|
|
1655
1019
|
#if os(tvOS)
|
|
1656
|
-
return .unsupported(
|
|
1020
|
+
return .unsupported(
|
|
1021
|
+
message: "coordinate drag is not supported on tvOS",
|
|
1022
|
+
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
|
|
1023
|
+
)
|
|
1657
1024
|
#else
|
|
1658
1025
|
let start = interactionCoordinate(app: app, x: x, y: y)
|
|
1659
1026
|
let end = interactionCoordinate(app: app, x: x2, y: y2)
|