mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e58cbcdb5f
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).
885 lines
29 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|