Files
callstack__agent-device/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift
T

1095 lines
35 KiB
Swift
Raw Normal View History

import XCTest
extension RunnerTests {
struct TouchVisualizationFrame {
let x: Double
let y: Double
let referenceWidth: Double
let referenceHeight: Double
}
struct DragVisualizationFrame {
let x: Double
let y: Double
let x2: Double
let y2: Double
let referenceWidth: Double
let referenceHeight: Double
}
struct DragPoints {
let x: Double
let y: Double
let x2: Double
let y2: Double
}
struct SelectorElementMatch {
let element: XCUIElement?
let isAmbiguous: Bool
let usedNonHittableFallback: Bool
}
// MARK: - Navigation Gestures
func tapInAppBackControl(app: XCUIApplication) -> Bool {
#if os(macOS)
if let back = macOSNavigationBackElement(app: app) {
tapElementCenter(app: app, element: back)
return true
}
return false
#elseif os(tvOS)
_ = pressTvRemote(.menu)
return true
#else
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
if let back = buttons.first(where: { $0.isHittable }) {
back.tap()
return true
}
return false
#endif
}
func performBackGesture(app: XCUIApplication) {
if pressTvRemote(.menu) {
return
}
performCoordinateBackGesture(app: app)
}
private func performCoordinateBackGesture(app: XCUIApplication) {
#if !os(tvOS)
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
start.press(forDuration: 0.05, thenDragTo: end)
#endif
}
func performSystemBackAction(app: XCUIApplication) -> Bool {
#if os(macOS)
return false
#else
if pressTvRemote(.menu) {
return true
}
performBackGesture(app: app)
return true
#endif
}
func performAppSwitcherGesture(app: XCUIApplication) {
if pressTvRemote(.home) {
sleepFor(resolveTvRemoteDoublePressDelay())
_ = pressTvRemote(.home)
return
}
performCoordinateAppSwitcherGesture(app: app)
}
private func performCoordinateAppSwitcherGesture(app: XCUIApplication) {
#if !os(tvOS)
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
start.press(forDuration: 0.6, thenDragTo: end)
#endif
}
func pressHomeButton() {
#if os(macOS)
return
#else
if pressTvRemote(.home) {
return
}
XCUIDevice.shared.press(.home)
#endif
}
func rotateDevice(to orientationName: String) -> Bool {
#if os(macOS) || os(tvOS)
return false
#else
switch orientationName {
case "portrait":
XCUIDevice.shared.orientation = .portrait
case "portrait-upside-down":
XCUIDevice.shared.orientation = .portraitUpsideDown
case "landscape-left":
XCUIDevice.shared.orientation = .landscapeLeft
case "landscape-right":
XCUIDevice.shared.orientation = .landscapeRight
default:
return false
}
sleepFor(0.2)
return true
#endif
}
func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
let element = app.descendants(matching: .any).matching(predicate).firstMatch
return element.exists ? element : nil
}
func findElement(
app: XCUIApplication,
selectorKey: String,
selectorValue: String,
allowNonHittableFallback: Bool = false
) -> SelectorElementMatch {
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
}
let predicate: NSPredicate
switch selectorKey {
case "id":
predicate = NSPredicate(format: "identifier ==[c] %@", value)
case "label":
predicate = NSPredicate(format: "label ==[c] %@", value)
case "value":
predicate = NSPredicate(format: "value ==[c] %@", value)
case "text":
predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value)
default:
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
}
var matchedElement: XCUIElement?
var nonHittableElement: XCUIElement?
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
for element in matches where element.exists {
if !element.isHittable {
if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
guard nonHittableElement == nil else {
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
}
nonHittableElement = element
}
continue
}
guard matchedElement == nil else {
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
}
matchedElement = element
}
if let matchedElement {
return SelectorElementMatch(element: matchedElement, isAmbiguous: false, usedNonHittableFallback: false)
}
return SelectorElementMatch(
element: nonHittableElement,
isAmbiguous: false,
usedNonHittableFallback: nonHittableElement != nil
)
}
private func hasTappableFrame(app: XCUIApplication, element: XCUIElement) -> Bool {
let frame = element.frame
if frame.isEmpty {
return false
}
let appFrame = app.frame
if appFrame.isEmpty {
return true
}
return appFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
}
func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
if match.isAmbiguous {
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
}
guard let element = match.element else {
return Response(ok: true, data: DataPayload(found: false, nodes: []))
}
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
let valueText = String(describing: element.value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let node = SnapshotNode(
index: 0,
type: elementTypeName(element.elementType),
label: label.isEmpty ? nil : label,
identifier: identifier.isEmpty ? nil : identifier,
value: valueText.isEmpty ? nil : valueText,
rect: snapshotRect(from: element.frame),
enabled: element.isEnabled,
focused: nil,
selected: element.isSelected ? true : nil,
hittable: element.isHittable,
depth: 0,
parentIndex: nil,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
return Response(
ok: true,
data: DataPayload(
text: readableText(for: element),
found: true,
nodes: [node]
)
)
}
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
let point = CGPoint(x: x, y: y)
let textInputCandidates = textInputCandidatesAt(app: app, point: point)
for element in textInputCandidates where prefersExpandedTextRead(element) {
if let text = readableText(for: element) {
return text
}
}
let candidates = app.descendants(matching: .any).allElementsBoundByIndex
.filter { element in
element.exists && !element.frame.isEmpty && element.frame.contains(point)
}
.sorted { left, right in
let leftArea = max(1, left.frame.width * left.frame.height)
let rightArea = max(1, right.frame.width * right.frame.height)
if leftArea != rightArea {
return leftArea < rightArea
}
if left.frame.minY != right.frame.minY {
return left.frame.minY < right.frame.minY
}
if left.frame.minX != right.frame.minX {
return left.frame.minX < right.frame.minX
}
return left.elementType.rawValue < right.elementType.rawValue
}
for element in candidates where prefersExpandedTextRead(element) {
if let text = readableText(for: element) {
return text
}
}
for element in candidates {
if let text = readableText(for: element) {
return text
}
}
return nil
}
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
}
private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
safely("TEXT_INPUT_AT_POINT", []) {
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633) * perf(ios): early-exit text-entry readiness when the keyboard is visible The XCUITest text-entry focus/readiness loops keyed their fast-exit on focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus predicates are stale there). As a result stabilizeTextInputBeforeTyping always burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full readinessTimeout (2.0s) in the normal case where the software keyboard appears — ~2.4s of dead wait before a single keystroke on every type/fill. The software keyboard becoming visible is the reliable iOS readiness signal, so both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char echo check and post-type verify/repair remain as drop safety nets. Measured on iPhone 17 sim (Settings search field), median type time: 25 chars: 3342ms -> 1379ms (2.4x) 52 chars: 3969ms -> 2190ms 313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing) Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified by read-back + screenshot); the lone miss triggered the existing verify/repair. * fix(ios): don't clear an already-empty text field (fixes fill mis-navigation) clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the element frame) + a 24-key delete burst, even when the field was empty. On a field that repositions on focus — e.g. the Settings search bar jumping bottom->top and revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an adjacent row (Developer), navigating away instead of clearing. fill (replace) into the search field went to the Developer pane (0/3 correct). Skip the clear entirely when the field's value is already empty (placeholder treated as empty): replacing into an empty field is a no-op, and skipping avoids the stray edge-tap. fill into the Settings search now types correctly and stays put: 5/5 exact (read-back + screenshot). * perf(ios): resolve text fields via typed queries, not full-tree enumeration textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY element) to find the text input at a point. fill drove this repeatedly: once it has coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll iteration whenever the focused-field reference goes stale (e.g. the Settings search bar repositioning bottom->top), so the full-tree enum dominated fill latency. Query the text-input element types directly (app.textFields/secureTextFields/ searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25 chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text. * fix(ios): address review — focus-change gate + secure-field clear Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was ALREADY up from a previous field (back-to-back fills), that is before first-responder moves to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:) helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct behavior) instead of the ~2.4s dead wait the fresh case avoids. Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip). Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
// Query the text-input element types directly instead of enumerating the entire tree
// (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
// slower it dominated fill latency because resolveTextEntryElement re-runs this on
// each verify/repair poll once the focused field reference goes stale).
// Prefer the smallest matching field so nested editable controls win over large containers.
[
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633) * perf(ios): early-exit text-entry readiness when the keyboard is visible The XCUITest text-entry focus/readiness loops keyed their fast-exit on focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus predicates are stale there). As a result stabilizeTextInputBeforeTyping always burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full readinessTimeout (2.0s) in the normal case where the software keyboard appears — ~2.4s of dead wait before a single keystroke on every type/fill. The software keyboard becoming visible is the reliable iOS readiness signal, so both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char echo check and post-type verify/repair remain as drop safety nets. Measured on iPhone 17 sim (Settings search field), median type time: 25 chars: 3342ms -> 1379ms (2.4x) 52 chars: 3969ms -> 2190ms 313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing) Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified by read-back + screenshot); the lone miss triggered the existing verify/repair. * fix(ios): don't clear an already-empty text field (fixes fill mis-navigation) clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the element frame) + a 24-key delete burst, even when the field was empty. On a field that repositions on focus — e.g. the Settings search bar jumping bottom->top and revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an adjacent row (Developer), navigating away instead of clearing. fill (replace) into the search field went to the Developer pane (0/3 correct). Skip the clear entirely when the field's value is already empty (placeholder treated as empty): replacing into an empty field is a no-op, and skipping avoids the stray edge-tap. fill into the Settings search now types correctly and stays put: 5/5 exact (read-back + screenshot). * perf(ios): resolve text fields via typed queries, not full-tree enumeration textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY element) to find the text input at a point. fill drove this repeatedly: once it has coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll iteration whenever the focused-field reference goes stale (e.g. the Settings search bar repositioning bottom->top), so the full-tree enum dominated fill latency. Query the text-input element types directly (app.textFields/secureTextFields/ searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25 chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text. * fix(ios): address review — focus-change gate + secure-field clear Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was ALREADY up from a previous field (back-to-back fills), that is before first-responder moves to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:) helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct behavior) instead of the ~2.4s dead wait the fresh case avoids. Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip). Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
app.textFields,
app.secureTextFields,
app.searchFields,
app.textViews,
]
.flatMap { $0.allElementsBoundByIndex }
.filter { element in
guard element.exists else { return false }
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633) * perf(ios): early-exit text-entry readiness when the keyboard is visible The XCUITest text-entry focus/readiness loops keyed their fast-exit on focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus predicates are stale there). As a result stabilizeTextInputBeforeTyping always burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full readinessTimeout (2.0s) in the normal case where the software keyboard appears — ~2.4s of dead wait before a single keystroke on every type/fill. The software keyboard becoming visible is the reliable iOS readiness signal, so both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char echo check and post-type verify/repair remain as drop safety nets. Measured on iPhone 17 sim (Settings search field), median type time: 25 chars: 3342ms -> 1379ms (2.4x) 52 chars: 3969ms -> 2190ms 313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing) Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified by read-back + screenshot); the lone miss triggered the existing verify/repair. * fix(ios): don't clear an already-empty text field (fixes fill mis-navigation) clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the element frame) + a 24-key delete burst, even when the field was empty. On a field that repositions on focus — e.g. the Settings search bar jumping bottom->top and revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an adjacent row (Developer), navigating away instead of clearing. fill (replace) into the search field went to the Developer pane (0/3 correct). Skip the clear entirely when the field's value is already empty (placeholder treated as empty): replacing into an empty field is a no-op, and skipping avoids the stray edge-tap. fill into the Settings search now types correctly and stays put: 5/5 exact (read-back + screenshot). * perf(ios): resolve text fields via typed queries, not full-tree enumeration textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY element) to find the text input at a point. fill drove this repeatedly: once it has coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll iteration whenever the focused-field reference goes stale (e.g. the Settings search bar repositioning bottom->top), so the full-tree enum dominated fill latency. Query the text-input element types directly (app.textFields/secureTextFields/ searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25 chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text. * fix(ios): address review — focus-change gate + secure-field clear Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was ALREADY up from a previous field (back-to-back fills), that is before first-responder moves to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:) helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct behavior) instead of the ~2.4s dead wait the fresh case avoids. Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip). Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
let frame = element.frame
return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
}
.sorted { left, right in
let leftArea = max(1, left.frame.width * left.frame.height)
let rightArea = max(1, right.frame.width * right.frame.height)
if leftArea != rightArea {
return leftArea < rightArea
}
if left.frame.minY != right.frame.minY {
return left.frame.minY < right.frame.minY
}
if left.frame.minX != right.frame.minX {
return left.frame.minX < right.frame.minX
}
return left.elementType.rawValue < right.elementType.rawValue
}
}
}
private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
point.x >= frame.minX - tolerance
&& point.x <= frame.maxX + tolerance
&& point.y >= frame.minY - tolerance
&& point.y <= frame.maxY + tolerance
}
func isKeyboardVisible(app: XCUIApplication) -> Bool {
return visibleKeyboardFrame(app: app) != nil
}
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
let wasVisible = isKeyboardVisible(app: app)
guard wasVisible else {
return (wasVisible: false, dismissed: false, visible: false)
}
#if os(tvOS)
_ = pressTvRemote(.menu)
sleepFor(0.2)
let visible = isKeyboardVisible(app: app)
return (wasVisible: true, dismissed: !visible, visible: visible)
#else
let keyboard = app.keyboards.firstMatch
keyboard.swipeDown()
sleepFor(0.2)
if !isKeyboardVisible(app: app) {
return (wasVisible: true, dismissed: true, visible: false)
}
if tapKeyboardDismissControl(app: app) {
sleepFor(0.2)
let visible = isKeyboardVisible(app: app)
return (wasVisible: true, dismissed: !visible, visible: visible)
}
if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) {
sleepFor(0.2)
let visible = isKeyboardVisible(app: app)
if !visible {
return (wasVisible: true, dismissed: true, visible: false)
}
}
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
#endif
}
func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
#if os(tvOS)
return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
#elseif os(iOS)
let wasVisible = isKeyboardVisible(app: app)
if tapKeyboardReturnControl(app: app) {
sleepFor(0.2)
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
}
var typed = false
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
app.typeText(XCUIKeyboardKey.return.rawValue)
typed = true
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
exceptionMessage
)
if let singleTarget = singleTextEntryElement(app: app) {
return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
}
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
}
sleepFor(0.2)
return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
#else
return (wasVisible: false, pressed: false, visible: false)
#endif
}
private func pressKeyboardReturn(
on element: XCUIElement,
app: XCUIApplication,
wasVisible: Bool
) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
element.tap()
element.typeText(XCUIKeyboardKey.return.rawValue)
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
exceptionMessage
)
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
}
sleepFor(0.2)
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
}
private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
#if os(iOS)
let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
guard element.exists else { return false }
switch element.elementType {
case .textField, .secureTextField, .searchField, .textView:
return true
default:
return false
}
}
}
return matches.count == 1 ? matches[0] : nil
#else
return nil
#endif
}
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
#if os(tvOS)
return false
#else
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
return false
}
for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
let candidates = [
app.keyboards.buttons[label],
app.keyboards.keys[label],
app.keyboards.toolbars.buttons[label],
]
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
hittable.tap()
return true
}
let toolbarButtonPredicate = NSPredicate(
format: "label == %@ OR identifier == %@",
label,
label
)
let toolbarButtons = app.toolbars.buttons
.matching(toolbarButtonPredicate)
.allElementsBoundByIndex
if let hittable = toolbarButtons.first(where: {
$0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
}) {
hittable.tap()
return true
}
}
return false
#endif
}
private func tapKeyboardReturnControl(
app: XCUIApplication,
allowCoordinateFallback: Bool = false
) -> Bool {
#if os(iOS)
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
let candidates = [
app.keyboards.buttons[label],
app.keyboards.keys[label],
]
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
hittable.tap()
return true
}
if allowCoordinateFallback,
let keyboardFrame = visibleKeyboardFrame(app: app),
let framed = candidates.first(where: {
guard $0.exists else { return false }
let frame = $0.frame
return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
}) {
let frame = framed.frame
switch tapAt(app: app, x: frame.midX, y: frame.midY) {
case .performed:
return true
case .unsupported:
return false
}
}
}
#endif
return false
}
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
let frame = element.frame
guard !frame.isEmpty && !keyboardFrame.isEmpty else {
return false
}
return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
}
private func readableText(for element: XCUIElement) -> String? {
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
let valueText = String(describing: element.value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
switch element.elementType {
case .textField, .secureTextField, .searchField, .textView:
if !valueText.isEmpty { return valueText }
if !label.isEmpty { return label }
return identifier.isEmpty ? nil : identifier
default:
if !label.isEmpty { return label }
if !valueText.isEmpty { return valueText }
return identifier.isEmpty ? nil : identifier
}
}
private func prefersExpandedTextRead(_ element: XCUIElement) -> Bool {
switch element.elementType {
case .textField, .secureTextField, .searchField, .textView:
return true
default:
return false
}
}
func findScopeElement(app: XCUIApplication, scope: String) -> XCUIElement? {
let predicate = NSPredicate(
format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@",
scope,
scope
)
let element = app.descendants(matching: .any).matching(predicate).firstMatch
return element.exists ? element : nil
}
func tapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "tap") {
return outcome
}
return performCoordinateTap(app: app, x: x, y: y)
}
func mouseClickAt(app: XCUIApplication, x: Double, y: Double, button: String) throws {
#if os(macOS)
let coordinate = interactionCoordinate(app: app, x: x, y: y)
switch button {
case "primary":
coordinate.tap()
case "secondary":
coordinate.rightClick()
case "middle":
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
)
default:
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
)
}
#elseif os(tvOS)
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "mouseClick is not supported on tvOS"]
)
#else
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
)
#endif
}
func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
guard case .performed = outcome else { return outcome }
sleepFor(0.1)
_ = pressTvRemote(.select)
return .performed
}
return performCoordinateDoubleTap(app: app, x: x, y: y)
}
func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
if let outcome = longSelectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), duration: duration) {
return outcome
}
return performCoordinateLongPress(app: app, x: x, y: y, duration: duration)
}
func dragAt(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
holdDuration: TimeInterval
) -> RunnerInteractionOutcome {
// tvOS has no coordinate drag. Preserve the direction as a focus move.
let dx = x2 - x
let dy = y2 - y
let button: TvRemoteButton = abs(dx) > abs(dy)
? (dx > 0 ? .right : .left)
: (dy > 0 ? .down : .up)
if pressTvRemote(button) {
return .performed
}
return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
}
func synthesizedDragAt(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double
) -> RunnerInteractionOutcome {
#if os(iOS)
if let message = RunnerSynthesizedGesture.synthesizeSwipe(
withApplication: app,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: durationMs
) {
return .unsupported(
message: message,
hint: "Falling back to XCTest coordinate drag may be slower; update Xcode if this persists."
)
}
return .performed
#elseif os(tvOS)
return .unsupported(
message: "coordinate drag is not supported on tvOS",
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
)
#else
return .unsupported(
message: "coordinate drag is not supported on macOS",
hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
)
#endif
}
func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
#if os(iOS)
if let message = RunnerSynthesizedGesture.synthesizeTap(
withApplication: app,
x: x,
y: y
) {
return .unsupported(
message: message,
hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
)
}
return .performed
#elseif os(tvOS)
return .unsupported(
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
)
#else
return .unsupported(
message: "synthesized coordinate tap is not supported on macOS",
hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
)
#endif
}
func keyboardAvoidingDragPoints(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double
) -> DragPoints {
let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
#if os(iOS)
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
return original
}
let minX = min(x, x2)
let minY = min(y, y2)
let gestureBounds = CGRect(
x: CGFloat(minX),
y: CGFloat(minY),
width: CGFloat(max(abs(x2 - x), 1)),
height: CGFloat(max(abs(y2 - y), 1))
)
guard gestureBounds.intersects(keyboardFrame) else {
return original
}
let window = app.windows.firstMatch
let appFrame = window.exists && !window.frame.isEmpty ? window.frame : app.frame
guard !appFrame.isEmpty else {
return original
}
let padding: Double = 12
let targetMaxY = Double(keyboardFrame.minY) - padding
let currentMaxY = max(y, y2)
let shift = currentMaxY - targetMaxY
guard shift > 0 else {
return original
}
let adjustedY = y - shift
let adjustedY2 = y2 - shift
guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
return original
}
NSLog(
"AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
x,
y,
x2,
y2,
x,
adjustedY,
x2,
adjustedY2,
Double(keyboardFrame.minY)
)
return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
#else
return original
#endif
}
func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
let appFrame = app.frame
let referenceFrame = resolvedTouchReferenceFrame(app: app, appFrame: appFrame)
let originX = appFrame.isEmpty ? referenceFrame.minX : appFrame.minX
let originY = appFrame.isEmpty ? referenceFrame.minY : appFrame.minY
return TouchVisualizationFrame(
x: originX + x,
y: originY + y,
referenceWidth: referenceFrame.width,
referenceHeight: referenceFrame.height
)
}
func resolvedDragVisualizationFrame(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double
) -> DragVisualizationFrame {
let start = resolvedTouchVisualizationFrame(app: app, x: x, y: y)
let end = resolvedTouchVisualizationFrame(app: app, x: x2, y: y2)
return DragVisualizationFrame(
x: start.x,
y: start.y,
x2: end.x,
y2: end.y,
referenceWidth: start.referenceWidth,
referenceHeight: start.referenceHeight
)
}
func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
let window = app.windows.firstMatch
if window.exists {
let windowFrame = window.frame
if !windowFrame.isEmpty {
return frameAvoidingKeyboard(app: app, frame: windowFrame)
}
}
if !appFrame.isEmpty {
return frameAvoidingKeyboard(app: app, frame: appFrame)
}
return CGRect(x: 0, y: 0, width: 0, height: 0)
}
private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
#if os(iOS)
guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
return frame
}
let intersection = frame.intersection(keyboardFrame)
guard !intersection.isNull && intersection.height > 0 else {
return frame
}
let keyboardCoverage = intersection.width / max(frame.width, 1)
guard keyboardCoverage >= 0.5 else {
return frame
}
let safeHeight = keyboardFrame.minY - frame.minY
guard safeHeight >= frame.height * 0.25 else {
return frame
}
return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
#else
return frame
#endif
}
private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
#if os(iOS)
return safely("KEYBOARD_FRAME") {
let keyboard = app.keyboards.firstMatch
guard keyboard.exists else { return nil }
let keyboardFrame = keyboard.frame
guard !keyboardFrame.isEmpty else { return nil }
return keyboardFrame
}
#else
return nil
#endif
}
func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
if performTvRemoteSwipeIfAvailable(direction: direction) {
let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
let midX = frame.midX
let midY = frame.midY
return DragVisualizationFrame(
x: midX,
y: midY,
x2: midX,
y2: midY,
referenceWidth: frame.width,
referenceHeight: frame.height
)
}
return nil
}
private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
switch direction {
case "up":
return pressTvRemote(.up)
case "down":
return pressTvRemote(.down)
case "left":
return pressTvRemote(.left)
case "right":
return pressTvRemote(.right)
default:
return false
}
}
func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
#if os(iOS)
// A coordinate tap+drag is a single-finger gesture: React Native reads it as a pan
// and the pinch scale never changes (#629). Drive the two-finger XCTest synthesis
// path (the same one transformGesture uses) with zero translation/rotation so RN's
// pinch recognizer actually fires.
let frame = interactionRoot(app: app).frame
let centerX = x ?? Double(frame.midX)
let centerY = y ?? Double(frame.midY)
return transformGesture(
app: app,
x: centerX,
y: centerY,
dx: 0,
dy: 0,
scale: scale,
degrees: 0,
durationMs: 300
)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
#elseif os(tvOS)
return .unsupported(
message: "pinch is not supported on tvOS",
hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
)
#else
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "pinch is not supported on macOS",
hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
)
#endif
}
func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
#if os(iOS)
// Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
// with zero translation/scale so React Native's rotation recognizer actually fires. The native
// XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
// handler does not read reliably the same class of problem #629/#634 fixed for pinch.
// velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
// keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
let frame = interactionRoot(app: app).frame
let centerX = x ?? Double(frame.midX)
let centerY = y ?? Double(frame.midY)
return transformGesture(
app: app,
x: centerX,
y: centerY,
dx: 0,
dy: 0,
scale: 1,
degrees: degrees,
durationMs: 300
)
#elseif os(tvOS)
return .unsupported(
message: "rotate-gesture is not supported on tvOS",
hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
)
#else
return .unsupported(
message: "rotate-gesture is not supported on macOS",
hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
)
#endif
}
func transformGesture(
app: XCUIApplication,
x: Double,
y: Double,
dx: Double,
dy: Double,
scale: Double,
degrees: Double,
durationMs: Double
) -> RunnerInteractionOutcome {
#if os(iOS)
let target = interactionRoot(app: app)
if let message = RunnerSynthesizedGesture.synthesizeTransform(
withApplication: app,
x: x,
y: y,
dx: dx,
dy: dy,
scale: scale,
degrees: degrees,
radius: transformGestureRadius(frame: target.frame, scale: scale),
durationMs: durationMs
) {
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: message,
hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
)
}
return .performed
#elseif os(tvOS)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "transformGesture is not supported on tvOS",
hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
)
#else
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "transformGesture is not supported on macOS",
hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
)
#endif
}
private func transformGestureRadius(frame: CGRect, scale: Double) -> Double {
let shorterSide = Double(min(frame.width, frame.height))
let frameRadius = shorterSide * 0.20
let minimumEndRadius = shorterSide * 0.08
let scaleAdjustedRadius = scale < 1.0 ? max(frameRadius, minimumEndRadius / scale) : frameRadius
return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
}
private func interactionRoot(app: XCUIApplication) -> XCUIElement {
let windows = app.windows.allElementsBoundByIndex
if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
return window
}
return app
}
private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
#if os(tvOS)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).tap()
return .performed
#endif
}
private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
#if os(tvOS)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).doubleTap()
return .performed
#endif
}
private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
#if os(tvOS)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
return .performed
#endif
}
private func performCoordinateDrag(
app: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
holdDuration: TimeInterval
) -> RunnerInteractionOutcome {
#if os(tvOS)
fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors (#645) * fix(ios): unify multi-touch gestures on two-finger synthesis + hinted unsupported errors Make RunnerSynthesizedGesture the single iOS multi-touch engine and drop the older incompatible models: - rotateGesture now drives the two-finger XCTest synthesis path (dx=dy=0, scale=1, degrees), mirroring the pinch migration in #634. The native XCUIElement.rotate(withVelocity:) injected a single synthetic rotation that React Native's rotation recognizer did not read reliably; synthesis fixes it. velocity is ignored on iOS (kept in the wire contract for compatibility; rotation direction comes from the sign of degrees). - pinch is now synthesis on iOS and a clear UNSUPPORTED_OPERATION on tvOS/macOS. The macOS coordinate double-tap+drag heuristic (performCoordinatePinch) is removed: synthesis is iOS-only, so macOS multi-touch is reported honestly as unsupported rather than approximated. - RunnerInteractionOutcome.unsupported now carries an actionable hint, mapped to ErrorPayload.hint (#639). Every unsupported gesture/tvOS path returns a concise message plus a next-step hint (existing messages kept verbatim). Net: on iOS, pinch/rotate/transform all flow through one synthesis primitive. swipe/scroll/pan/ fling remain single-finger drags (correct, unchanged). Coverage: examples/test-app/replays/gesture-lab.ad exercises pinch + rotate against the gesture lab and asserts "pinch changed yes" / "rotate changed yes". * fix(ios): fail-fast macOS pinch + align capability/docs with synthesis-only Follow-through for removing the macOS coordinate pinch path (the runner now returns UNSUPPORTED_OPERATION for macOS pinch): reject it at admission instead of round-tripping. - capabilities.ts: pinch now matches rotate-gesture/transform-gesture (Android + iOS simulator only); macOS dropped. Removes the now-unused isMacOsOrMobileAppleSimulator helper. - capabilities.test.ts: pinch expected unsupported on macOS and tvOS. - website/docs/docs/commands.md: pinch listed for Android + iOS simulators only (removed from the macOS app-session list); documents that iOS rotate ignores the optional velocity arg (synthesis uses a fixed duration; direction comes from the sign of degrees). Addresses PR #645 review HIGH #2 and MEDIUM #3. * fix(ios): surface a hinted unsupported error for synthesis gestures at admission Removing macOS pinch from the capability matrix makes macOS pinch (and the already-excluded rotate-gesture/transform-gesture on macOS/tvOS/physical iOS) fail fast in ensureGenericCommandReady before reaching the runner. That left the runner's macOS-specific hint unreachable on the daemon path, so callers only saw the generic "<cmd> is not supported on this device". Add an optional unsupportedHint to the capability matrix and surface it at admission, so the synthesis-only gestures fail fast (no runner round-trip) AND return an actionable hint pointing to where they work (Android + iOS simulator). Applied to pinch / rotate-gesture / transform-gesture. Addresses PR #645 review (P2: route macOS pinch to the hinted failure).
2026-06-01 19:30:36 +02:00
return .unsupported(
message: "coordinate drag is not supported on tvOS",
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
)
#else
let start = interactionCoordinate(app: app, x: x, y: y)
let end = interactionCoordinate(app: app, x: x2, y: y2)
start.press(forDuration: holdDuration, thenDragTo: end)
return .performed
#endif
}
#if !os(tvOS)
private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
#if os(iOS)
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
return origin.withOffset(CGVector(dx: x, dy: y))
#else
let root = interactionRoot(app: app)
let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
let rootFrame = root.frame
let offsetX = x - Double(rootFrame.origin.x)
let offsetY = y - Double(rootFrame.origin.y)
return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
#endif
}
#endif
private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
let frame = element.frame
if !frame.isEmpty {
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
return
}
#if !os(tvOS)
element.tap()
#endif
}
private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
let predicate = NSPredicate(
format: "identifier == %@ OR label == %@",
"go back",
"Back"
)
let element = app.descendants(matching: .any).matching(predicate).firstMatch
return element.exists ? element : nil
}
}