Files
callstack__agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift
T
Michał Pierzchała e58cbcdb5f refactor: colocate native platform sources under android/, apple/, linux/ (#1273)
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:

- android-ime-helper/        -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/   -> android/snapshot-helper/
- apple-runner/              -> apple/runner/
- macos-helper/              -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py

Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.

Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
2026-07-15 21:47:38 +02:00

885 lines
29 KiB
Swift

import XCTest
// Text entry & keyboard-readiness for the runner: the focus -> type -> verify -> repair
// pipeline, readiness polling, and field clearing. Behavior-preserving extraction from
// RunnerTests+Interaction.swift (no logic changes) to keep that file navigable.
extension RunnerTests {
enum TextTypingRepairMode {
case none
case append
case replacement
}
enum TextEntryTiming {
static let focusTimeout: TimeInterval = 0.4
static let readinessTimeout: TimeInterval = 2.0
static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
static let pollInterval: TimeInterval = 0.02
static let warmupValueTimeout: TimeInterval = 0.4
static let verificationStabilityWindow: TimeInterval = 0.2
}
struct TextEntryResult {
let verified: Bool?
let repaired: Bool
let expectedText: String?
let observedText: String?
}
struct TextEntryTarget {
let element: XCUIElement?
let refreshPoint: CGPoint?
let prefersFocusedElement: Bool
func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
guard let nextElement else {
return self
}
let frame = nextElement.frame
let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
return TextEntryTarget(
element: nextElement,
refreshPoint: point,
prefersFocusedElement: prefersFocusedElement
)
}
}
struct TextEntryStabilization {
let element: XCUIElement?
let focusConfirmed: Bool
}
func clearTextInput(_ element: XCUIElement) {
// Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
// field is empty. Why skip: the edge-tap computes a point from the element frame, which can
// be stale after the field repositions on focus (e.g. the Settings search bar jumps
// bottom->top and reveals a "Suggestions" list) tapping there navigates away instead of
// clearing; and replacing into an already-empty field is a no-op anyway.
// editableTextValue returns nil for secure (and unknown) fields, where we CANNOT confirm
// emptiness those must still be cleared, or replace would concatenate stale + new text.
// So distinguish nil (clear) from "" (skip).
if let existing = editableTextValue(for: element, treatingPlaceholderAsEmpty: true),
existing.isEmpty {
return
}
#if !os(tvOS)
moveCaretToEnd(element: element)
#endif
let count = estimatedDeleteCount(for: element)
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
element.typeText(deletes)
}
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
#if os(iOS)
// iOS focus predicates can return stale or misleading text-input matches
// under XCUITest, so text entry readiness is driven by tap/keyboard state.
return nil
#else
return safely("FOCUSED_INPUT_QUERY") {
let candidates = app
.descendants(matching: .any)
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
.allElementsBoundByIndex
for candidate in candidates where candidate.exists {
switch candidate.elementType {
case .textField, .secureTextField, .searchField, .textView:
return candidate
default:
continue
}
}
return nil
}
#endif
}
func stabilizeTextInputBeforeTyping(
app: XCUIApplication,
target: XCUIElement?,
keyboardVisibleBeforeTap: Bool? = nil
) -> TextEntryStabilization {
#if os(tvOS)
return TextEntryStabilization(element: target, focusConfirmed: true)
#else
let latest = target
let keyboardVisibleAtEntry = keyboardVisibleBeforeTap ?? isKeyboardVisible(app: app)
let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
while Date() < deadline {
if let focused = focusedTextInput(app: app) {
return TextEntryStabilization(element: focused, focusConfirmed: true)
}
// focusedTextInput is intentionally nil on iOS; treat the keyboard transitioning to
// visible after our tap as the focus-moved signal. Don't fast-path when it was already up.
if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
return TextEntryStabilization(element: latest, focusConfirmed: true)
}
sleepFor(TextEntryTiming.pollInterval)
}
return TextEntryStabilization(element: latest, focusConfirmed: false)
#endif
}
func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
guard let x, let y else {
// Bare `type` targets the current first responder. On iOS we intentionally do not trust
// `hasKeyboardFocus`, but an already-visible software keyboard is sufficient evidence that
// app.typeText has a receiver; waiting the full readiness timeout cannot prove a stronger
// target because there is no selector/coordinate focus move to validate.
if isKeyboardVisible(app: app) {
return TextEntryTarget(
element: focusedTextInput(app: app),
refreshPoint: nil,
prefersFocusedElement: true
)
}
let focused = waitForTextEntryReadiness(
app: app,
target: TextEntryTarget(
element: focusedTextInput(app: app),
refreshPoint: nil,
prefersFocusedElement: true
)
)
return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
}
let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
let target = textInputAt(app: app, x: x, y: y)
let requestedPoint = CGPoint(x: x, y: y)
if let target {
let frame = target.frame
if !frame.isEmpty {
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
} else {
_ = tapAt(app: app, x: x, y: y)
}
} else {
_ = tapAt(app: app, x: x, y: y)
}
// A visible keyboard is not enough evidence for app.typeText, because focus may still
// belong to a previous field. With a concrete target we type through XCUIElement.typeText,
// so after tapping it the iOS readiness timeout cannot prove anything stronger.
if keyboardVisibleBeforeTap, let target {
return TextEntryTarget(
element: target,
refreshPoint: textEntryRefreshPoint(for: target) ?? requestedPoint,
prefersFocusedElement: false
)
}
let stabilized = stabilizeTextInputBeforeTyping(
app: app,
target: target,
keyboardVisibleBeforeTap: keyboardVisibleBeforeTap
)
let readyTarget = TextEntryTarget(
element: stabilized.element ?? target,
refreshPoint: requestedPoint,
prefersFocusedElement: false
)
let concreteTargetReady = keyboardVisibleBeforeTap && readyTarget.element != nil
let element = stabilized.focusConfirmed || concreteTargetReady
? (stabilized.element ?? target)
: (waitForTextEntryReadiness(app: app, target: readyTarget) ?? stabilized.element ?? target)
return TextEntryTarget(
element: element,
refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
prefersFocusedElement: false
)
}
func focusTextInputForTextEntry(app: XCUIApplication, element: XCUIElement) -> TextEntryTarget {
let point = textEntryRefreshPoint(for: element)
let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
if let point {
_ = tapAt(app: app, x: point.x, y: point.y)
}
// See the coordinate-target path above: direct element typing keeps this scoped to the
// tapped target, while the first-character warmup and final verify still catch dropped input.
if keyboardVisibleBeforeTap {
return TextEntryTarget(
element: element,
refreshPoint: textEntryRefreshPoint(for: element) ?? point,
prefersFocusedElement: false
)
}
let stabilized = stabilizeTextInputBeforeTyping(
app: app,
target: element,
keyboardVisibleBeforeTap: keyboardVisibleBeforeTap
)
let readyTarget = TextEntryTarget(
element: stabilized.element ?? element,
refreshPoint: point,
prefersFocusedElement: false
)
let resolved = stabilized.focusConfirmed
? (stabilized.element ?? element)
: (waitForTextEntryReadiness(app: app, target: readyTarget) ?? stabilized.element ?? element)
return TextEntryTarget(
element: resolved,
refreshPoint: textEntryRefreshPoint(for: resolved) ?? point,
prefersFocusedElement: false
)
}
func isTextEntryElement(_ element: XCUIElement) -> Bool {
switch element.elementType {
case .textField, .secureTextField, .searchField, .textView:
return true
default:
return false
}
}
func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
switch command.textEntryMode {
case "append":
return .append
case "replace":
return .replacement
default:
return command.clearFirst == true ? .replacement : .none
}
}
func typeTextReliably(
app: XCUIApplication,
target: TextEntryTarget,
text: String,
delaySeconds: Double,
repairMode: TextTypingRepairMode = .none,
commandId: String? = nil
) -> TextEntryResult {
let totalStartedAt = Date()
guard !text.isEmpty else {
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: 0, mode: repairMode)
return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
}
var activeTarget = target
let initialResolveStartedAt = Date()
let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
activeTarget = activeTarget.withElement(initialTarget)
let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
let initialText = repairMode == .append ? currentText : nil
let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
logTextEntryPhase(
commandId: commandId,
phase: "initial-resolve",
startedAt: initialResolveStartedAt,
chars: text.count,
mode: repairMode
)
if repairMode == .replacement {
guard let replacementTarget = initialTarget else {
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
}
if currentText == nil || currentText?.isEmpty == false {
let clearStartedAt = Date()
clearTextInput(replacementTarget)
activeTarget = activeTarget.withElement(replacementTarget)
logTextEntryPhase(
commandId: commandId,
phase: "clear",
startedAt: clearStartedAt,
chars: text.count,
mode: repairMode
)
}
}
func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
currentTarget.typeText(value)
return currentTarget
} else {
app.typeText(value)
return resolveTextEntryElement(app: app, target: activeTarget)
}
}
func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
guard let expectedValue else {
sleepFor(TextEntryTiming.pollInterval)
return
}
let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
while Date() < deadline {
if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
return
}
sleepFor(TextEntryTiming.pollInterval)
}
}
let characters = Array(text)
if delaySeconds > 0 && characters.count > 1 {
var typedTarget: XCUIElement?
let delayedTypeStartedAt = Date()
for (index, character) in characters.enumerated() {
typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
if index + 1 < characters.count {
sleepFor(delaySeconds)
}
}
logTextEntryPhase(
commandId: commandId,
phase: "type-delayed",
startedAt: delayedTypeStartedAt,
chars: characters.count,
mode: repairMode
)
if repairMode == .none {
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
}
let verifyStartedAt = Date()
let result = verifyTextEntryWithRepairIfNeeded(
app: app,
target: activeTarget.withElement(typedTarget),
expectedText: expectedText,
repairMode: repairMode
)
logTextEntryPhase(
commandId: commandId,
phase: "verify",
startedAt: verifyStartedAt,
chars: characters.count,
mode: repairMode
)
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
return result
}
let typedTarget: XCUIElement?
if repairMode != .none && characters.count > 1 {
let firstCharacter = String(characters[0])
let firstStartedAt = Date()
var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
logTextEntryPhase(
commandId: commandId,
phase: "type-first",
startedAt: firstStartedAt,
chars: 1,
mode: repairMode
)
activeTarget = activeTarget.withElement(firstTypedTarget)
let warmupExpectedText = expectedTextEntryValue(
typedText: firstCharacter,
mode: repairMode,
initialText: initialText
)
let warmupStartedAt = Date()
waitForWarmupValue(warmupExpectedText, target: activeTarget)
logTextEntryPhase(
commandId: commandId,
phase: "warmup",
startedAt: warmupStartedAt,
chars: 1,
mode: repairMode
)
let remainingText = String(characters.dropFirst())
let remainingStartedAt = Date()
firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
logTextEntryPhase(
commandId: commandId,
phase: "type-remaining",
startedAt: remainingStartedAt,
chars: characters.count - 1,
mode: repairMode
)
typedTarget = firstTypedTarget
} else {
let typeStartedAt = Date()
typedTarget = typeIntoCurrentTarget(text)
logTextEntryPhase(
commandId: commandId,
phase: "type-all",
startedAt: typeStartedAt,
chars: characters.count,
mode: repairMode
)
}
if repairMode == .none {
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
}
let verifyStartedAt = Date()
let result = verifyTextEntryWithRepairIfNeeded(
app: app,
target: activeTarget.withElement(typedTarget),
expectedText: expectedText,
repairMode: repairMode
)
logTextEntryPhase(
commandId: commandId,
phase: "verify",
startedAt: verifyStartedAt,
chars: characters.count,
mode: repairMode
)
logTextEntryPhase(commandId: commandId, phase: "total", startedAt: totalStartedAt, chars: text.count, mode: repairMode)
return result
}
private func logTextEntryPhase(
commandId: String?,
phase: String,
startedAt: Date,
chars: Int,
mode: TextTypingRepairMode
) {
NSLog(
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=%@ durationMs=%.1f chars=%d mode=%@",
commandId ?? "",
phase,
Date().timeIntervalSince(startedAt) * 1000.0,
chars,
textEntryModeName(mode)
)
}
func textEntryModeName(_ mode: TextTypingRepairMode) -> String {
switch mode {
case .none:
return "none"
case .append:
return "append"
case .replacement:
return "replacement"
}
}
private func verifyTextEntryWithRepairIfNeeded(
app: XCUIApplication,
target: TextEntryTarget,
expectedText: String?,
repairMode: TextTypingRepairMode
) -> TextEntryResult {
let initialResult = verifyTextEntry(
app: app,
target: target,
expectedText: expectedText,
repaired: false
)
#if os(iOS)
guard initialResult.verified == false,
let expectedText = initialResult.expectedText
else {
return initialResult
}
guard shouldRepairTextEntry(
app: app,
target: target,
expectedText: expectedText,
repairMode: repairMode
) else {
return verifyTextEntry(
app: app,
target: target,
expectedText: expectedText,
repaired: false
)
}
guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
return initialResult
}
let observedText = editableTextValue(for: repairTarget) ?? ""
NSLog(
"AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
expectedText.count,
observedText.count
)
clearTextInput(repairTarget)
repairTarget.typeText(expectedText)
return verifyTextEntry(
app: app,
target: target.withElement(repairTarget),
expectedText: expectedText,
repaired: true
)
#else
return initialResult
#endif
}
private func verifyTextEntry(
app: XCUIApplication,
target: TextEntryTarget,
expectedText: String?,
repaired: Bool
) -> TextEntryResult {
let targetElement = resolveTextEntryElement(app: app, target: target)
guard let expectedText else {
return TextEntryResult(
verified: nil,
repaired: repaired,
expectedText: nil,
observedText: editableTextValue(for: targetElement)
)
}
guard let observedText = editableTextValue(for: targetElement) else {
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
}
guard textEntryValueMatchesExpected(targetElement, observedText: observedText, expectedText: expectedText) else {
return TextEntryResult(
verified: false,
repaired: repaired,
expectedText: expectedText,
observedText: observedText
)
}
let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
var latestObservedText = observedText
while Date() < stableDeadline {
sleepFor(TextEntryTiming.pollInterval)
guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
}
latestObservedText = nextObservedText
guard textEntryValueMatchesExpected(
resolveTextEntryElement(app: app, target: target),
observedText: nextObservedText,
expectedText: expectedText
) else {
return TextEntryResult(
verified: false,
repaired: repaired,
expectedText: expectedText,
observedText: nextObservedText
)
}
}
return TextEntryResult(
verified: true,
repaired: repaired,
expectedText: expectedText,
observedText: latestObservedText
)
}
private func textEntryValueMatchesExpected(
_ element: XCUIElement?,
observedText: String,
expectedText: String
) -> Bool {
if observedText == expectedText {
return true
}
guard hasTextEntrySubmitSuffix(expectedText), element?.elementType != .textView else {
return false
}
var submittedText = expectedText
while hasTextEntrySubmitSuffix(submittedText) {
submittedText.removeLast()
}
return observedText == submittedText
}
private func hasTextEntrySubmitSuffix(_ text: String) -> Bool {
text.hasSuffix("\n") || text.hasSuffix("\r")
}
private func expectedTextEntryValue(
typedText: String,
mode: TextTypingRepairMode,
initialText: String?
) -> String? {
switch mode {
case .none:
return nil
case .append:
guard let initialText else {
return nil
}
return initialText + typedText
case .replacement:
return typedText
}
}
private func shouldRepairTextEntry(
app: XCUIApplication,
target: TextEntryTarget,
expectedText: String,
repairMode: TextTypingRepairMode
) -> Bool {
#if os(iOS)
var latestObservedText: String?
let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
repeat {
guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
return false
}
if textEntryValueMatchesExpected(
resolveTextEntryElement(app: app, target: target),
observedText: observedText,
expectedText: expectedText
) {
return false
}
latestObservedText = observedText
if !isRepairableTextEntryMismatch(
observedText: observedText,
expectedText: expectedText,
repairMode: repairMode
) {
return false
}
sleepFor(TextEntryTiming.pollInterval)
} while Date() < deadline
guard let latestObservedText else {
return false
}
guard !textEntryValueMatchesExpected(
resolveTextEntryElement(app: app, target: target),
observedText: latestObservedText,
expectedText: expectedText
) else {
return false
}
return isRepairableTextEntryMismatch(
observedText: latestObservedText,
expectedText: expectedText,
repairMode: repairMode
)
#else
return false
#endif
}
private func isRepairableTextEntryMismatch(
observedText: String,
expectedText: String,
repairMode: TextTypingRepairMode
) -> Bool {
guard observedText != expectedText else {
return false
}
if repairMode == .replacement {
return true
}
return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
observedText: observedText,
expectedText: expectedText
)
}
private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
guard observedText.count < expectedText.count else {
return false
}
let missingCharacterCount = expectedText.count - observedText.count
guard missingCharacterCount <= max(2, expectedText.count / 4) else {
return false
}
var expectedIndex = expectedText.startIndex
for character in observedText {
guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
return false
}
expectedIndex = expectedText.index(after: matchIndex)
}
return true
}
private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
if target.prefersFocusedElement {
if let focused = focusedTextInput(app: app) {
return focused
}
if let element = target.element, element.exists {
return element
}
} else {
if let element = target.element, element.exists {
return element
}
}
if let refreshPoint = target.refreshPoint,
let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
return refreshed
}
if let focused = focusedTextInput(app: app) {
return focused
}
return nil
}
private func waitForTextEntryReadiness(
app: XCUIApplication,
target: TextEntryTarget,
timeout: TimeInterval = TextEntryTiming.readinessTimeout
) -> XCUIElement? {
#if os(iOS)
var latest = resolveTextEntryElement(app: app, target: target)
let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
let deadline = Date().addingTimeInterval(timeout)
let hardwareKeyboardFallback = Date().addingTimeInterval(
min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
)
var sawSoftwareKeyboard = false
while Date() < deadline {
if let focused = focusedTextInput(app: app) {
latest = focused
if isKeyboardVisible(app: app) {
return focused
}
}
// Fast-path on a keyboard hidden->visible transition: our tapped field gained focus, so
// return immediately instead of burning the full readinessTimeout (warmup-first-char echo
// + post-type verify/repair remain as drop safety nets). When the keyboard was ALREADY up
// (back-to-back fills), this isn't a focus signal fall through to the settle/timeout so
// text isn't sent to the previously-focused field.
if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
return latest
}
sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
return latest
}
sleepFor(TextEntryTiming.pollInterval)
}
return focusedTextInput(app: app) ?? latest
#else
return resolveTextEntryElement(app: app, target: target)
#endif
}
func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {
#if os(iOS)
switch element.elementType {
case .textField, .secureTextField, .searchField, .textView:
if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil {
return
}
let frame = element.frame
if !frame.isEmpty {
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
_ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout)
}
default:
return
}
#endif
}
private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let focused = focusedTextInput(app: app) {
return focused
}
sleepFor(TextEntryTiming.pollInterval)
}
return focusedTextInput(app: app)
}
private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
guard let element else {
return nil
}
let frame = element.frame
guard !frame.isEmpty else {
return nil
}
return CGPoint(x: frame.midX, y: frame.midY)
}
/// A focus-moved signal for iOS text entry, where `focusedTextInput` is intentionally nil.
/// The software keyboard TRANSITIONING from hidden (at entry) to visible means the field we
/// just tapped gained first-responder. If the keyboard was ALREADY up (e.g. back-to-back
/// fills into different fields), its visibility is not evidence focus moved to the new field,
/// so callers must keep waiting rather than typing into the previously-focused field.
private func keyboardBecameVisible(app: XCUIApplication, wasVisibleAtEntry: Bool) -> Bool {
return !wasVisibleAtEntry && isKeyboardVisible(app: app)
}
private func keyboardElementExists(app: XCUIApplication) -> Bool {
#if os(iOS)
return safely("KEYBOARD_EXISTS", false) { app.keyboards.firstMatch.exists }
#else
return false
#endif
}
private func moveCaretToEnd(element: XCUIElement) {
#if os(tvOS)
return
#else
let frame = element.frame
guard !frame.isEmpty else {
element.tap()
return
}
let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
let target = origin.withOffset(
CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
)
target.tap()
#endif
}
private func estimatedDeleteCount(for element: XCUIElement) -> Int {
let valueText = normalizedElementText(element.value)
let base = valueText.isEmpty ? 24 : (valueText.count + 8)
return max(24, min(120, base))
}
private func normalizedElementText(_ value: Any?) -> String {
String(describing: value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func editableTextValue(
for element: XCUIElement?,
treatingPlaceholderAsEmpty: Bool = false
) -> String? {
guard let element else {
return nil
}
switch element.elementType {
case .textField, .searchField, .textView:
let value = String(describing: element.value ?? "")
if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
return ""
}
return value
case .secureTextField:
return nil
default:
return nil
}
}
private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty else {
return false
}
let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !placeholder.isEmpty && normalizedValue == placeholder {
return true
}
if isGenericTextInputLabel(normalizedValue) {
return true
}
let normalizedLabel = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedLabel == normalizedValue && isGenericTextInputLabel(normalizedLabel)
}
private func isGenericTextInputLabel(_ value: String) -> Bool {
switch value {
case "Text input field":
return true
default:
return false
}
}
}