Skip to content

Commit ed3e970

Browse files
committed
fix: finalize Maestro replay compatibility
1 parent 78be55c commit ed3e970

48 files changed

Lines changed: 2563 additions & 357 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,25 @@ extension RunnerTests {
742742
dismissed: result.dismissed
743743
)
744744
)
745+
case .keyboardReturn:
746+
let result = pressKeyboardReturn(app: activeApp)
747+
if !result.pressed {
748+
return Response(
749+
ok: false,
750+
error: ErrorPayload(
751+
code: "UNSUPPORTED_OPERATION",
752+
message: "Unable to press the iOS keyboard return key"
753+
)
754+
)
755+
}
756+
return Response(
757+
ok: true,
758+
data: DataPayload(
759+
message: "keyboardReturn",
760+
visible: result.visible,
761+
wasVisible: result.wasVisible
762+
)
763+
)
745764
case .alert:
746765
let action = (command.action ?? "get").lowercased()
747766
guard let alert = resolveAlert(app: activeApp) else {
@@ -852,7 +871,27 @@ extension RunnerTests {
852871
}
853872
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
854873
let textEntryMode = resolveTextEntryMode(command)
855-
let target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
874+
let target: TextEntryTarget
875+
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
876+
let match = findElement(
877+
app: activeApp,
878+
selectorKey: selectorKey,
879+
selectorValue: selectorValue,
880+
allowNonHittableFallback: command.allowNonHittableSelectorTap == true
881+
)
882+
if match.isAmbiguous {
883+
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
884+
}
885+
guard let element = match.element else {
886+
return Response(ok: false, error: ErrorPayload(code: "NO_MATCH", message: "selector did not match an element"))
887+
}
888+
guard isTextEntryElement(element) else {
889+
return Response(ok: false, error: ErrorPayload(code: "INVALID_TARGET", message: "selector did not match a text input"))
890+
}
891+
target = focusTextInputForTextEntry(app: activeApp, element: element)
892+
} else {
893+
target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
894+
}
856895
if textEntryMode == .replacement {
857896
guard target.element != nil else {
858897
let message =
@@ -880,6 +919,17 @@ extension RunnerTests {
880919
)
881920
)
882921
}
883-
return Response(ok: true, data: DataPayload(message: textResult.repaired ? "typed after repair" : "typed"))
922+
let point = target.refreshPoint
923+
let frame = activeApp.frame
924+
return Response(
925+
ok: true,
926+
data: DataPayload(
927+
message: textResult.repaired ? "typed after repair" : "typed",
928+
x: point.map { Double($0.x) },
929+
y: point.map { Double($0.y) },
930+
referenceWidth: frame.isEmpty ? nil : Double(frame.width),
931+
referenceHeight: frame.isEmpty ? nil : Double(frame.height)
932+
)
933+
)
884934
}
885935
}

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 201 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ extension RunnerTests {
335335
switch element.elementType {
336336
case .textField, .secureTextField, .searchField, .textView:
337337
let frame = element.frame
338-
return !frame.isEmpty && frame.contains(point)
338+
return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
339339
default:
340340
return false
341341
}
@@ -366,20 +366,31 @@ extension RunnerTests {
366366
return matched
367367
}
368368

369+
private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
370+
point.x >= frame.minX - tolerance
371+
&& point.x <= frame.maxX + tolerance
372+
&& point.y >= frame.minY - tolerance
373+
&& point.y <= frame.maxY + tolerance
374+
}
375+
369376
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
377+
#if os(iOS)
378+
return nil
379+
#else
370380
var focused: XCUIElement?
371381
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
372-
let candidate = app
382+
let candidates = app
373383
.descendants(matching: .any)
374384
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
375-
.firstMatch
376-
guard candidate.exists else { return }
377-
378-
switch candidate.elementType {
379-
case .textField, .secureTextField, .searchField, .textView:
380-
focused = candidate
381-
default:
382-
return
385+
.allElementsBoundByIndex
386+
for candidate in candidates where candidate.exists {
387+
switch candidate.elementType {
388+
case .textField, .secureTextField, .searchField, .textView:
389+
focused = candidate
390+
return
391+
default:
392+
continue
393+
}
383394
}
384395
})
385396
if let exceptionMessage {
@@ -390,6 +401,7 @@ extension RunnerTests {
390401
return nil
391402
}
392403
return focused
404+
#endif
393405
}
394406

395407
func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
@@ -449,6 +461,36 @@ extension RunnerTests {
449461
)
450462
}
451463

464+
func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
465+
let point = textEntryRefreshPoint(for: element)
466+
if let point {
467+
_ = tapAt(app: app, x: point.x, y: point.y)
468+
}
469+
let stabilized = stabilizeTextInputBeforeTyping(app: app, target: element)
470+
let resolved = waitForTextEntryReadiness(
471+
app: app,
472+
target: TextEntryTarget(
473+
element: stabilized ?? element,
474+
refreshPoint: point,
475+
prefersFocusedElement: false
476+
)
477+
) ?? stabilized ?? element
478+
return TextEntryTarget(
479+
element: resolved,
480+
refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
481+
prefersFocusedElement: false
482+
)
483+
}
484+
485+
func isTextEntryElement(_ element: XCUIElement) -> Bool {
486+
switch element.elementType {
487+
case .textField, .secureTextField, .searchField, .textView:
488+
return true
489+
default:
490+
return false
491+
}
492+
}
493+
452494
func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
453495
switch command.textEntryMode {
454496
case "append":
@@ -629,7 +671,7 @@ extension RunnerTests {
629671
guard let observedText = editableTextValue(for: targetElement) else {
630672
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
631673
}
632-
guard observedText == expectedText else {
674+
guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
633675
return TextEntryResult(
634676
verified: false,
635677
repaired: repaired,
@@ -645,7 +687,11 @@ extension RunnerTests {
645687
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
646688
}
647689
latestObservedText = nextObservedText
648-
guard nextObservedText == expectedText else {
690+
guard textEntryValueMatchesExpected(
691+
resolveTextEntryElement(app: app, target: target),
692+
observedText: nextObservedText,
693+
expectedText: expectedText
694+
) else {
649695
return TextEntryResult(
650696
verified: false,
651697
repaired: repaired,
@@ -662,6 +708,28 @@ extension RunnerTests {
662708
)
663709
}
664710

711+
private func textEntryValueMatchesExpected(
712+
_ element: XCUIElement?,
713+
observedText: String,
714+
expectedText: String
715+
) -> Bool {
716+
if observedText == expectedText {
717+
return true
718+
}
719+
guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
720+
return false
721+
}
722+
var submittedText = expectedText
723+
while hasTextEntrySubmitSuffix(submittedText) {
724+
submittedText.removeLast()
725+
}
726+
return observedText == submittedText
727+
}
728+
729+
private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
730+
text.hasSuffix("\n") || text.hasSuffix("\r")
731+
}
732+
665733
private func expectedTextEntryValue(
666734
typedText: String,
667735
mode: TextTypingRepairMode,
@@ -693,7 +761,11 @@ extension RunnerTests {
693761
guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
694762
return false
695763
}
696-
if observedText == expectedText {
764+
if textEntryValueMatchesExpected(
765+
resolveTextEntryElement(app: app, target: target),
766+
observedText: observedText,
767+
expectedText: expectedText
768+
) {
697769
return false
698770
}
699771
latestObservedText = observedText
@@ -710,7 +782,11 @@ extension RunnerTests {
710782
guard let latestObservedText else {
711783
return false
712784
}
713-
guard latestObservedText != expectedText else {
785+
guard !textEntryValueMatchesExpected(
786+
resolveTextEntryElement(app: app, target: target),
787+
observedText: latestObservedText,
788+
expectedText: expectedText
789+
) else {
714790
return false
715791
}
716792
return isRepairableTextEntryMismatch(
@@ -904,6 +980,85 @@ extension RunnerTests {
904980
#endif
905981
}
906982

983+
func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
984+
#if os(tvOS)
985+
return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
986+
#elseif os(iOS)
987+
let wasVisible = isKeyboardVisible(app: app)
988+
if tapKeyboardReturnControl(app: app) {
989+
sleepFor(0.2)
990+
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
991+
}
992+
993+
var typed = false
994+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
995+
app.typeText(XCUIKeyboardKey.return.rawValue)
996+
typed = true
997+
})
998+
if let exceptionMessage {
999+
NSLog(
1000+
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
1001+
exceptionMessage
1002+
)
1003+
if let singleTarget = singleTextEntryElement(app: app) {
1004+
return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
1005+
}
1006+
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
1007+
}
1008+
sleepFor(0.2)
1009+
return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
1010+
#else
1011+
return (wasVisible: false, pressed: false, visible: false)
1012+
#endif
1013+
}
1014+
1015+
private func pressKeyboardReturn(
1016+
on element: XCUIElement,
1017+
app: XCUIApplication,
1018+
wasVisible: Bool
1019+
) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
1020+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1021+
element.tap()
1022+
element.typeText(XCUIKeyboardKey.return.rawValue)
1023+
})
1024+
if let exceptionMessage {
1025+
NSLog(
1026+
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
1027+
exceptionMessage
1028+
)
1029+
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
1030+
}
1031+
sleepFor(0.2)
1032+
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
1033+
}
1034+
1035+
private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
1036+
#if os(iOS)
1037+
var matches: [XCUIElement] = []
1038+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1039+
matches = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
1040+
guard element.exists else { return false }
1041+
switch element.elementType {
1042+
case .textField, .secureTextField, .searchField, .textView:
1043+
return true
1044+
default:
1045+
return false
1046+
}
1047+
}
1048+
})
1049+
if let exceptionMessage {
1050+
NSLog(
1051+
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TEXT_ENTRY_QUERY_IGNORED_EXCEPTION=%@",
1052+
exceptionMessage
1053+
)
1054+
return nil
1055+
}
1056+
return matches.count == 1 ? matches[0] : nil
1057+
#else
1058+
return nil
1059+
#endif
1060+
}
1061+
9071062
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
9081063
#if os(tvOS)
9091064
return false
@@ -941,6 +1096,22 @@ extension RunnerTests {
9411096
#endif
9421097
}
9431098

1099+
private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
1100+
#if os(iOS)
1101+
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
1102+
let candidates = [
1103+
app.keyboards.buttons[label],
1104+
app.keyboards.keys[label],
1105+
]
1106+
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
1107+
hittable.tap()
1108+
return true
1109+
}
1110+
}
1111+
#endif
1112+
return false
1113+
}
1114+
9441115
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
9451116
let frame = element.frame
9461117
guard !frame.isEmpty && !keyboardFrame.isEmpty else {
@@ -1003,11 +1174,24 @@ extension RunnerTests {
10031174
guard !normalizedValue.isEmpty else {
10041175
return false
10051176
}
1006-
guard let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines),
1007-
!placeholder.isEmpty else {
1177+
let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1178+
if !placeholder.isEmpty && normalizedValue == placeholder {
1179+
return true
1180+
}
1181+
if isGenericTextInputLabel(normalizedValue) {
1182+
return true
1183+
}
1184+
let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
1185+
return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
1186+
}
1187+
1188+
private func isGenericTextInputLabel(_ value: String) -> Bool {
1189+
switch value {
1190+
case "Text input field":
1191+
return true
1192+
default:
10081193
return false
10091194
}
1010-
return normalizedValue == placeholder
10111195
}
10121196

10131197
private func readableText(for element: XCUIElement) -> String? {

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum CommandType: String, Codable {
2323
case rotate
2424
case appSwitcher
2525
case keyboardDismiss
26+
case keyboardReturn
2627
case alert
2728
case pinch
2829
case rotateGesture

0 commit comments

Comments
 (0)