Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ static NSTimeInterval AgentDeviceTextEntryAcknowledgeWindow(void) {
// Edits further apart than this belong to different bursts: one runner command's characters arrive
// well inside it, and two commands are separated by at least a commit-wait poll and a status read.
static const NSTimeInterval AgentDeviceTextEntryBurstBreakSeconds = 1.0;
static const NSUInteger AgentDeviceTextEntryAutoSubmitLength = 6;

- (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
// A field whose app owns its value, the way a controlled React Native `TextInput` does. A burst
Expand Down Expand Up @@ -310,6 +311,28 @@ - (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
textField.text.length > 0) {
[textField removeFromSuperview];
}
// An auto-submitting one-time-code field: the last digit navigates away, to a screen whose own
// input takes the code field's place or to one without an input. `replace-after-input` is the
// same navigation landing before the code is complete.
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
BOOL codeComplete = textField.text.length >= AgentDeviceTextEntryAutoSubmitLength;
if (textField.superview == nil) {
return;
}
if ((codeComplete && [arguments containsObject:@"--agent-device-text-entry-auto-submit"]) ||
(textField.text.length > 0 && [arguments containsObject:@"--agent-device-text-entry-replace-after-input"])) {
UITextField *nextScreenField = [[UITextField alloc] initWithFrame:textField.frame];
if (![arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
nextScreenField.accessibilityIdentifier = @"agent-device-auto-submit-next-screen-input";
}
nextScreenField.borderStyle = UITextBorderStyleRoundedRect;
[textField.superview addSubview:nextScreenField];
[textField removeFromSuperview];
[nextScreenField becomeFirstResponder];
} else if (codeComplete &&
[arguments containsObject:@"--agent-device-text-entry-auto-submit-without-successor"]) {
[textField removeFromSuperview];
}
}
#endif

Expand Down Expand Up @@ -356,7 +379,9 @@ - (void)viewDidLoad {
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-count-value"];
UITextField *textField =
digitCountValue ? [[AgentDeviceDigitCountTextField alloc] init] : [[UITextField alloc] init];
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
if (![NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
}
textField.borderStyle = UITextBorderStyleRoundedRect;
// An empty input view keeps the software keyboard down, which is the hardware-keyboard responder
// these routes are addressed to. `--agent-device-text-entry-soft-keyboard` leaves the real input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,39 +115,90 @@ extension RunnerTests {
var unconfirmed: TextEntryUnconfirmedEvidence? = nil
}

/// The input a text entry command bound to. XCUIElement exposes no per-instance id, and a query
/// bound by index or point re-resolves to whatever input occupies that slot now, so element type
/// plus identifier is the identity. It is unique only when the identifier is non-empty. Unlike
/// `TextEntryElementIdentity` it leaves out the frame: it must hold across every post of a fill,
/// while the field can still be moving with the keyboard.
struct TextEntryInputIdentity: Equatable {
let elementType: XCUIElement.ElementType
let identifier: String
Comment thread
okwasniewski marked this conversation as resolved.

var isDistinguishable: Bool { !identifier.isEmpty }
}

struct TextEntryTarget {
let element: XCUIElement?
let refreshPoint: CGPoint?
let prefersFocusedElement: Bool
let fromTapWitness: Bool
/// The input the first resolved element was. Once bound, resolution refuses any other input.
let boundIdentity: TextEntryInputIdentity?

init(
element: XCUIElement?,
refreshPoint: CGPoint?,
prefersFocusedElement: Bool,
fromTapWitness: Bool = false
fromTapWitness: Bool = false,
boundIdentity: TextEntryInputIdentity? = nil
) {
self.element = element
self.refreshPoint = refreshPoint
self.prefersFocusedElement = prefersFocusedElement
self.fromTapWitness = fromTapWitness
self.boundIdentity = boundIdentity
}

func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
guard let nextElement else {
return self
}
let frame = nextElement.frame
// Reading `frame` from an input the app has removed records an XCTest failure; a snapshot
// only throws, after XCTest waits about two seconds for a match, so it is skipped when the
// input is already gone.
let snapshot = nextElement.exists ? try? nextElement.snapshot() : nil
let frame = snapshot?.frame ?? .zero
let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
return TextEntryTarget(
element: nextElement,
refreshPoint: point,
prefersFocusedElement: prefersFocusedElement,
fromTapWitness: fromTapWitness
fromTapWitness: fromTapWitness,
boundIdentity: boundIdentity ?? snapshot.map {
TextEntryInputIdentity(elementType: $0.elementType, identifier: $0.identifier)
Comment thread
okwasniewski marked this conversation as resolved.
}
)
}
}

/// What one snapshot of a candidate element proved.
enum TextEntryInputProbe: Equatable {
case input(TextEntryInputIdentity)
/// XCTest found no element for the query: the only error that proves absence.
case noMatch
/// Any other snapshot failure, such as a transient accessibility error or multiple matches.
case unavailable
}

static let xCTestUITestingErrorDomain = "com.apple.dt.xctest.ui-testing.error"
/// `snapshot()` of a query that matches nothing, as for an input the app removed. A query with
/// several matches throws 10006 and one into an app that is not running throws 10001.
static let xCTestNoMatchesErrorCode = 10008

/// Classifies a candidate's `snapshot()` error: only XCTest's no-match proves the input is gone.
static func textEntryInputProbe(snapshotError error: Error) -> TextEntryInputProbe {
let error = error as NSError
return error.domain == xCTestUITestingErrorDomain && error.code == xCTestNoMatchesErrorCode
? .noMatch
: .unavailable
}

/// Repair clears and retypes whatever input resolves, so it needs an identity that a
/// successor input cannot share. An unbound target keeps its unguarded repair.
static func textEntryRepairCanTarget(boundIdentity: TextEntryInputIdentity?) -> Bool {
boundIdentity?.isDistinguishable ?? true
}

struct TextEntryStabilization {
let element: XCUIElement?
let focusConfirmed: Bool
Expand Down Expand Up @@ -204,7 +255,91 @@ extension RunnerTests {
}
}

/// Resolves the target's input. A bound target accepts only an element whose snapshot carries
/// its bound identity, so every post, read-back, verification poll, and repair refuses an input
/// that took the bound one's place.
func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
guard let boundIdentity = target.boundIdentity else {
return resolveUnboundTextEntryElement(app: app, target: target)
}
func isBoundInput(_ candidate: XCUIElement) -> Bool {
candidate.exists && probeTextEntryInput(candidate) == .input(boundIdentity)
}
if target.prefersFocusedElement, let focused = focusedTextInput(app: app), isBoundInput(focused) {
return focused
}
if let element = target.element, isBoundInput(element) {
return element
}
if let refreshPoint = target.refreshPoint,
case .matches(let candidates) = probeTextInputs(app: app, point: refreshPoint),
let match = candidates.first(where: isBoundInput) {
return match
}
if let focused = focusedTextInput(app: app), isBoundInput(focused) {
return focused
}
if let byIdentifier = boundTextEntryInputQuery(app: app, identity: boundIdentity), isBoundInput(byIdentifier) {
return byIdentifier
}
return nil
}

/// Whether a bound target's input is proven gone: XCTest found no match for it, or a different
/// input answers in its place, and nothing at its point carries its identity. A failed probe
/// proves nothing. Unbound targets and unidentified inputs without an element cannot be proven
/// gone.
func boundTextEntryInputIsGone(app: XCUIApplication, target: TextEntryTarget) -> Bool {
guard let boundIdentity = target.boundIdentity,
let query = boundTextEntryInputQuery(app: app, identity: boundIdentity) ?? target.element
else {
return false
}
switch probeTextEntryInput(query) {
case .input(let identity) where identity != boundIdentity:
break
case .noMatch:
break
case .input, .unavailable:
return false
}
guard let refreshPoint = target.refreshPoint else {
return true
}
switch probeTextInputs(app: app, point: refreshPoint) {
case .absent:
return true
case .matches(let candidates):
return candidates.allSatisfy { probeTextEntryInput($0) != .input(boundIdentity) }
case .unavailable:
return false
}
}

/// The app-wide query for an input whose identifier is unique enough to search by.
private func boundTextEntryInputQuery(app: XCUIApplication, identity: TextEntryInputIdentity) -> XCUIElement? {
guard identity.isDistinguishable else {
return nil
}
return app.descendants(matching: identity.elementType).matching(identifier: identity.identifier).element
}

/// Snapshots one candidate: its identity, a proven no-match, or a failure that proves nothing.
private func probeTextEntryInput(_ element: XCUIElement) -> TextEntryInputProbe {
var probe = TextEntryInputProbe.unavailable
let (_, exception) = catchingObjCException(fallback: ()) {
do {
let snapshot = try element.snapshot()
probe = .input(TextEntryInputIdentity(elementType: snapshot.elementType, identifier: snapshot.identifier))
} catch {
probe = Self.textEntryInputProbe(snapshotError: error)
}
}
return exception == nil ? probe : .unavailable
}

/// Resolution for a target not bound to an input yet: the first element that exists.
private func resolveUnboundTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
if target.prefersFocusedElement {
if let focused = focusedTextInput(app: app) {
return focused
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ extension RunnerTests {
textEntryRoute = "xctest-element"
currentTarget.typeText(value)
return (currentTarget, nil)
} else if activeTarget.prefersFocusedElement && isKeyboardVisible(app: app) {
} else if activeTarget.boundIdentity == nil && activeTarget.prefersFocusedElement && isKeyboardVisible(app: app) {
#if os(iOS)
// Two ways this post leaves the synthesized channel, and both hand the text to
// application-wide typing: the command's own budget refused it, or XCTest's private synthesis
Expand Down Expand Up @@ -396,6 +396,21 @@ extension RunnerTests {
repairMode: TextTypingRepairMode,
baseline: TextEntryObservation?
) -> TextEntryResult {
if target.boundIdentity != nil, resolveTextEntryElement(app: app, target: target) == nil {
guard boundTextEntryInputIsGone(app: app, target: target) else {
return TextEntryResult(
verified: nil,
repaired: false,
expectedText: expectedText,
observedText: nil,
failure: .commitNotObserved
)
}
// Every character was delivered and the app then removed the input, as an auto-submitting
// code field does. Its value can no longer be read back.
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_INPUT_REMOVED_AFTER_DELIVERY")
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
Comment thread
okwasniewski marked this conversation as resolved.
}
let initialResult = verifyTextEntry(
app: app,
target: target,
Expand Down Expand Up @@ -431,6 +446,15 @@ extension RunnerTests {
repaired: false
)
}
guard Self.textEntryRepairCanTarget(boundIdentity: target.boundIdentity) else {
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_REPAIR_REFUSED reason=unidentified-input")
return verifyTextEntry(
app: app,
target: target,
expectedText: expectedText,
repaired: false
)
}

guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
return initialResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,31 @@ extension RunnerTests {
XCTAssertFalse(TextEntryFailure.commitNotObserved.message.contains("only part"))
}

// Codes observed from `snapshot()` on an iOS 27.0 simulator: 10008 for an input the app removed,
// 10006 for a query with several matches, 10001 for an app that is not running.
func testOnlyXCTestNoMatchProvesATextEntryInputIsGone() {
let domain = Self.xCTestUITestingErrorDomain
let cases: [(error: NSError, expected: TextEntryInputProbe)] = [
(NSError(domain: domain, code: 10008), .noMatch),
(NSError(domain: domain, code: 10006), .unavailable),
(NSError(domain: domain, code: 10001), .unavailable),
(NSError(domain: "com.example.other", code: 10008), .unavailable),
]
for testCase in cases {
XCTAssertEqual(Self.textEntryInputProbe(snapshotError: testCase.error), testCase.expected, String(describing: testCase))
}
}

func testTextEntryRepairNeedsAnIdentityASuccessorCannotShare() {
XCTAssertTrue(Self.textEntryRepairCanTarget(boundIdentity: nil))
XCTAssertTrue(
Self.textEntryRepairCanTarget(boundIdentity: TextEntryInputIdentity(elementType: .textField, identifier: "code"))
)
XCTAssertFalse(
Self.textEntryRepairCanTarget(boundIdentity: TextEntryInputIdentity(elementType: .textField, identifier: ""))
)
}

#if os(iOS)
func testSynthesizedTextEntryFallsBackOnlyWhenPrivateSynthesisIsUnavailable() {
XCTAssertEqual(
Expand Down
Loading
Loading