diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index 32f11186ef..944db7818c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -74,6 +74,7 @@ @interface AgentDeviceRunnerViewController : UIViewController @property(nonatomic, assign) NSUInteger textEntryBurstEdits; @property(nonatomic, assign) NSTimeInterval textEntryBurstMinGap; @property(nonatomic, assign) NSTimeInterval textEntryAcknowledgeWindowSeconds; +@property(nonatomic, strong) NSLayoutConstraint *textEntryFieldTop; @property(nonatomic, assign) BOOL alertFixtureStarted; @property(nonatomic, strong) NSTimer *alertActivationBusyBackstop; @property(nonatomic, strong) NSTimer *alertBannerRepost; @@ -279,6 +280,10 @@ static NSTimeInterval AgentDeviceTextEntryAcknowledgeWindow(void) { static const NSTimeInterval AgentDeviceTextEntryBurstBreakSeconds = 1.0; static const NSUInteger AgentDeviceTextEntryAutoSubmitLength = 6; +static const CGFloat AgentDeviceTextEntryFieldTopInset = 24; +static const CGFloat AgentDeviceTextEntryFieldHeight = 44; +static const CGFloat AgentDeviceTextEntryNeighbourGap = 16; + - (void)agentDeviceTextEntryDidChange:(UITextField *)textField { // A field whose app owns its value, the way a controlled React Native `TextInput` does. A burst // typed faster than the app renders loses the characters that arrived while a render was in @@ -334,6 +339,15 @@ - (void)agentDeviceTextEntryDidChange:(UITextField *)textField { [textField removeFromSuperview]; } } + +// Moves the field up by its own height plus the gap below it when it gains focus, the way keyboard +// avoidance or a bottom sheet extending above the keyboard does, so the neighbouring field slides +// into the point the focus tap hit. +- (void)agentDeviceTextEntryDidBeginEditing:(UITextField *)textField { + self.textEntryFieldTop.constant = + AgentDeviceTextEntryFieldTopInset - AgentDeviceTextEntryFieldHeight - AgentDeviceTextEntryNeighbourGap; + [self.view layoutIfNeeded]; +} #endif - (void)viewDidLoad { @@ -395,12 +409,33 @@ - (void)viewDidLoad { forControlEvents:UIControlEventEditingChanged]; textField.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:textField]; + self.textEntryFieldTop = [textField.topAnchor constraintEqualToAnchor:label.bottomAnchor + constant:AgentDeviceTextEntryFieldTopInset]; [NSLayoutConstraint activateConstraints:@[ [textField.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], - [textField.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24], + self.textEntryFieldTop, [textField.widthAnchor constraintEqualToConstant:240], - [textField.heightAnchor constraintEqualToConstant:44], + [textField.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight], ]]; + if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-moves-on-focus"]) { + textField.text = @"stale"; + [textField addTarget:self + action:@selector(agentDeviceTextEntryDidBeginEditing:) + forControlEvents:UIControlEventEditingDidBegin]; + UITextField *neighbour = [[UITextField alloc] init]; + neighbour.accessibilityIdentifier = @"agent-device-text-entry-neighbour"; + neighbour.borderStyle = UITextBorderStyleRoundedRect; + neighbour.text = @"neighbour"; + neighbour.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:neighbour]; + [NSLayoutConstraint activateConstraints:@[ + [neighbour.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [neighbour.topAnchor constraintEqualToAnchor:textField.bottomAnchor + constant:AgentDeviceTextEntryNeighbourGap], + [neighbour.widthAnchor constraintEqualToConstant:240], + [neighbour.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight], + ]]; + } if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) { self.textEntryAcknowledgeWindowSeconds = AgentDeviceTextEntryAcknowledgeWindow(); // Reports how many edits this app rendered and how many writes it had to make because a diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift index 08edcf6cd8..005f69b8de 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift @@ -67,7 +67,8 @@ extension RunnerTests { /// already reported success. The expected value is the /// final text itself, and a settled mismatch is always reported: see /// `awaitSynthesizedReplacementCommitOutcome`'s doc comment. Text carrying a submit key is - /// skipped outright: the app may clear or rewrite the field on submit. + /// skipped outright: the app may clear or rewrite the field on submit. So is a secure field, + /// whose value is never readable. func awaitSynthesizedReplacementCommit( app: XCUIApplication, target: TextEntryTarget, @@ -82,6 +83,7 @@ extension RunnerTests { let outcome = Self.awaitSynthesizedReplacementCommitOutcome( expectedText: expectedText, placeholder: ingredients.placeholder, + fieldIsSecure: ingredients.fieldIsSecure, now: { Date() }, observe: ingredients.observe, waitForNextObservation: ingredients.waitForNextObservation @@ -132,13 +134,16 @@ extension RunnerTests { expectedText: String ) -> ( placeholder: String?, + fieldIsSecure: Bool, observe: () -> String?, waitForNextObservation: () -> Void ) { - let placeholder = resolveTextEntryElement(app: app, target: target)?.placeholderValue + let field = resolveTextEntryElement(app: app, target: target) + let placeholder = field?.placeholderValue let waitStartedAt = Date() return ( placeholder: placeholder, + fieldIsSecure: field?.elementType == .secureTextField, observe: { let observedText = self.editableTextValue( for: self.resolveTextEntryElement(app: app, target: target), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 454f309cb1..2753a40007 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -89,8 +89,8 @@ extension RunnerTests { /// One post. `characterCount` characters of the text, taken in order, are what it types. struct Step: Equatable { let characterCount: Int - /// True when this post selects the field's existing value away first. That selection is its - /// own synthesize record, so the post costs one call more than typing characters. + /// True when this post clears the field's existing value before typing its text in a + /// record of its own; `maxClearPassCount` says how. var replacesExistingText = false /// Seconds charged for what follows this post: the `--delay-ms` gap before the next post, or /// the warmup read-back after a peeled first character. The read-back costs one poll, because @@ -100,9 +100,28 @@ extension RunnerTests { /// Set on the post whose value the loop waits for before the rest is posted. var warmsUpField = false + /// Most clear passes a replacing post runs, each a select-all record and a delete key record + /// followed by a read of the field. In a React Native app launched moments earlier (iOS 26.5 + /// simulator) select-alls were dropped, so a pass deleted only the last character, and two + /// fixed passes still left "Clie" of "Client" in 3 of 5 runs. The post clears until the field + /// reads empty; typing over text still there would append to it. + static let maxClearPassCount = 4 + /// Clear passes a field that never exposes its value gets before it is typed into unverified. + static let unreadableClearPassCount = 2 + /// How many private synthesize records this post runs. var synthesizeCallCount: Int { - replacesExistingText ? 2 : 1 + replacesExistingText ? 2 * Self.maxClearPassCount + 1 : 1 + } + + /// Keystrokes this post types at the synthesized pace, the clears' delete keys included. + var keystrokeCount: Int { + characterCount + clearReadCount + } + + /// Reads of the field this post makes between its clear passes. + var clearReadCount: Int { + replacesExistingText ? Self.maxClearPassCount : 0 } } @@ -129,8 +148,9 @@ extension RunnerTests { var seconds: TimeInterval { steps.reduce(0) { total, step in total - + Double(step.characterCount) * TextEntryTiming.synthesizedCharacterInterval + + Double(step.keystrokeCount) * TextEntryTiming.synthesizedCharacterInterval + Double(step.synthesizeCallCount) * TextEntryTiming.synthesizeCallOverhead + + Double(step.clearReadCount) * TextEntryTiming.synthesizedClearReadAllowance + step.pauseAfterSeconds } } @@ -226,6 +246,48 @@ extension RunnerTests { case stop } + /// What the field reads between a replacement's clear passes. + enum ClearedFieldRead: Equatable { + case text(String) + /// The field never exposes its value: a secure field. + case unreadable + /// The read could not answer, or no input resolves. + case unavailable + } + + /// How a replacing post's clear ended. + enum SynthesizedClearOutcome: Equatable { + case cleared + /// The field never exposes its value, so it is typed into unverified, as the commit wait leaves it. + case unverified + /// The field still held text after the last pass, or could not be read. Nothing is typed. + case notCleared + /// A clear post stopped the plan. + case stopped + } + + /// Clears until the field reads empty, at most `maxClearPassCount` passes. A read that cannot + /// answer ends the clear, so the post never types over text it could not see removed. + static func clearForSynthesizedReplacement( + clearOnce: () -> SynthesizedStepDispatch, + read: () -> ClearedFieldRead + ) -> SynthesizedClearOutcome { + for pass in 1...SynthesizedTextPlan.Step.maxClearPassCount { + guard clearOnce() == .posted else { return .stopped } + switch read() { + case .text(let value) where value.isEmpty: + return .cleared + case .text: + continue + case .unreadable: + if pass >= SynthesizedTextPlan.Step.unreadableClearPassCount { return .unverified } + case .unavailable: + return .notCleared + } + } + return .notCleared + } + struct SynthesizedPlanRun { let postedCharacterCount: Int /// True when a post stopped the plan before its last step. @@ -298,32 +360,68 @@ extension RunnerTests { ) ) } + var clearOutcome: SynthesizedClearOutcome? // A private synthesis channel that is gone mid-plan leaves the command the same // point-and-focus fallback it had before the plan existed. let synthesisAvailable = runSynthesizedTextPlan( plan, text: request.text, post: { slice, step in - switch request.synthesizer.enterText( - app: request.app, - text: slice, - replacingExistingText: step.replacesExistingText - ) { - case .continueTyping: - return .posted - case .fallback: - return .stop - case .raise(let message): - NSException( - name: NSExceptionName.internalInconsistencyException, - reason: message ?? "private XCTest text synthesis failed" - ).raise() - return .stop + func enter(_ text: String, replacingExistingText: Bool) -> SynthesizedStepDispatch { + switch request.synthesizer.enterText( + app: request.app, + text: text, + replacingExistingText: replacingExistingText + ) { + case .continueTyping: + return .posted + case .fallback: + return .stop + case .raise(let message): + NSException( + name: NSExceptionName.internalInconsistencyException, + reason: message ?? "private XCTest text synthesis failed" + ).raise() + return .stop + } } + if step.replacesExistingText { + var passes = 0 + let outcome = Self.clearForSynthesizedReplacement( + clearOnce: { + passes += 1 + return enter(XCUIKeyboardKey.delete.rawValue, replacingExistingText: true) + }, + read: { self.clearedFieldRead(app: request.app, target: request.target) } + ) + clearOutcome = outcome + NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR passes=%d outcome=%@", passes, String(describing: outcome)) + guard outcome == .cleared || outcome == .unverified else { return .stop } + } + return enter(slice, replacingExistingText: false) }, // A replacement never peels a warmup character, so no step asks for a read-back. waitAfterWarmupCharacter: { _ in } ) + if clearOutcome == .notCleared { + logTextEntryPhase( + commandId: request.commandId, + phase: "total", + startedAt: request.startedAt, + chars: request.text.count, + mode: .replacement + ) + return .completed( + TextEntryResult( + verified: nil, + repaired: false, + expectedText: request.text, + observedText: nil, + textEntryRoute: "synthesized-first-responder-replacement", + failure: .clearNotObserved + ) + ) + } if synthesisAvailable.stoppedEarly { NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable") guard let point = request.target.refreshPoint else { return .notApplicable } @@ -400,7 +498,7 @@ extension RunnerTests { enum SynthesizedTextCommitOutcome: Equatable { /// The field holds exactly the expected text. case settled - /// There was nothing to wait for: the text carries a submit key. + /// There was nothing to wait for: the text carries a submit key, or the field is secure. case unobservable /// The deadline expired with the expected text still not observed. case notObserved @@ -432,12 +530,18 @@ extension RunnerTests { static func awaitSynthesizedReplacementCommitOutcome( expectedText: String, placeholder: String?, + fieldIsSecure: Bool = false, stallBudget: TimeInterval = TextEntryTiming.synthesizedCommitStallTimeout, ceiling: TimeInterval = TextEntryTiming.synthesizedCommitCeiling, now: () -> Date = { Date() }, observe: () -> String?, waitForNextObservation: () -> Void ) -> SynthesizedTextCommitOutcome { + // A secure field never exposes its value, so every read is nil and the wait could only expire. + // The element route leaves such a field unverified rather than failed; so does this one. + if fieldIsSecure { + return .unobservable + } // A placeholder-equal AX value cannot prove a commit: an input handler may clear the field // after dispatch, making the empty field render the same value. Refuse before polling because // no later read can distinguish those states. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index 4c039d18ec..7b369fab00 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -8,6 +8,7 @@ extension RunnerTests { case notFocused = "TEXT_INPUT_NOT_FOCUSED" case synthesisUnavailable = "TEXT_INPUT_SYNTHESIS_UNAVAILABLE" case commitNotObserved = "TEXT_INPUT_COMMIT_NOT_OBSERVED" + case clearNotObserved = "TEXT_INPUT_CLEAR_NOT_OBSERVED" case synthesisBudgetExceeded = "TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED" var message: String { @@ -18,6 +19,8 @@ extension RunnerTests { return "Reliable text synthesis is unavailable while the software keyboard is hidden." case .commitNotObserved: return "The runner could not confirm the typed text reached the field." + case .clearNotObserved: + return "The runner could not clear the field before typing, so it typed nothing." case .synthesisBudgetExceeded: return "The text is longer than one runner command can type at this pace." } @@ -31,6 +34,8 @@ extension RunnerTests { return "Show the software keyboard, then retry type." case .commitNotObserved: return "The field may hold none, part, or all of the text. Run snapshot -i and inspect the field: if it already matches, continue; otherwise retry fill with the full text quoted and --delay-ms \(TextEntryTiming.recoveryDelayMilliseconds). Do not use type, which appends to whatever committed." + case .clearNotObserved: + return "The field may still hold some or all of its old text. Run snapshot -i and inspect the field, then retry fill. Do not use type, which appends to whatever the field holds." case .synthesisBudgetExceeded: let recoveryDelay = TextEntryTiming.recoveryDelayMilliseconds let recoveryBudget = SynthesizedDeliveryBudget.maxTextLength( @@ -70,8 +75,15 @@ extension RunnerTests { /// delivery happens before this wait starts and is bounded by `synthesizedDeliveryCeiling`. static let synthesizedCommitCeiling: TimeInterval = 10.0 /// What a synthesized replacement spends before its first character: focusing the field took - /// 374–500 ms through the daemon on an iPhone 17 Pro simulator. - static let synthesizedReplacementFocusAllowance: TimeInterval = 2.0 + /// 374–500 ms through the daemon on an iPhone 17 Pro simulator, and finding the input under the + /// point before that tap took up to 2.0 s more on a React Native bottom sheet (iPhone 17 Pro + /// Max, iOS 26.5). + static let synthesizedReplacementFocusAllowance: TimeInterval = 4.0 + /// What one read of the field between a replacement's clear passes is charged. On a penalized + /// channel, a clear pass on a React Native bottom-sheet field took 1.18–1.26 s over 22 passes + /// (iPhone 17 Pro Max, iOS 26.5); less the pass's charged synthesize calls and delete key, the + /// read took 0.80–0.88 s. A login field read in 0.26–0.40 s. + static let synthesizedClearReadAllowance: TimeInterval = 0.9 /// How long a synthesized burst may spend posting its characters: what the command's /// main-thread watchdog leaves after focus and the longest commit wait. The private synthesize /// call delivers as it returns, so text that does not fit is refused before the first character @@ -127,6 +139,14 @@ extension RunnerTests { var isDistinguishable: Bool { !identifier.isEmpty } } + /// A text input found under a point, and the identity it carried when found. The element is an + /// index-bound query handle: an input inserted or reordered ahead of it later re-binds the handle + /// to that input, and the identity is what notices. + struct TextInputAtPoint { + let element: XCUIElement + let identity: TextEntryInputIdentity + } + struct TextEntryTarget { let element: XCUIElement? let refreshPoint: CGPoint? @@ -134,19 +154,27 @@ extension RunnerTests { let fromTapWitness: Bool /// The input the first resolved element was. Once bound, resolution refuses any other input. let boundIdentity: TextEntryInputIdentity? + /// The text input that sat under `refreshPoint` before the focus tap, which binds the target to + /// its identity. Focusing a field can move the layout (keyboard avoidance, a bottom sheet + /// extending above the keyboard), after which the point hits a different field or none, so once + /// this is set the point no longer names the field. It identifies the field for reads and + /// clears, never for routing. + let inputAtRefreshPoint: TextInputAtPoint? init( element: XCUIElement?, refreshPoint: CGPoint?, prefersFocusedElement: Bool, fromTapWitness: Bool = false, - boundIdentity: TextEntryInputIdentity? = nil + boundIdentity: TextEntryInputIdentity? = nil, + inputAtRefreshPoint: TextInputAtPoint? = nil ) { self.element = element self.refreshPoint = refreshPoint self.prefersFocusedElement = prefersFocusedElement self.fromTapWitness = fromTapWitness - self.boundIdentity = boundIdentity + self.boundIdentity = boundIdentity ?? inputAtRefreshPoint?.identity + self.inputAtRefreshPoint = inputAtRefreshPoint } func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget { @@ -166,7 +194,8 @@ extension RunnerTests { fromTapWitness: fromTapWitness, boundIdentity: boundIdentity ?? snapshot.map { TextEntryInputIdentity(elementType: $0.elementType, identifier: $0.identifier) - } + }, + inputAtRefreshPoint: inputAtRefreshPoint ) } } @@ -271,7 +300,11 @@ extension RunnerTests { if let element = target.element, isBoundInput(element) { return element } - if let refreshPoint = target.refreshPoint, + if let input = target.inputAtRefreshPoint, textInputStillResolves(input) { + return input.element + } + if target.inputAtRefreshPoint == nil, + let refreshPoint = target.refreshPoint, case .matches(let candidates) = probeTextInputs(app: app, point: refreshPoint), let match = candidates.first(where: isBoundInput) { return match @@ -325,7 +358,7 @@ extension RunnerTests { } /// Snapshots one candidate: its identity, a proven no-match, or a failure that proves nothing. - private func probeTextEntryInput(_ element: XCUIElement) -> TextEntryInputProbe { + func probeTextEntryInput(_ element: XCUIElement) -> TextEntryInputProbe { var probe = TextEntryInputProbe.unavailable let (_, exception) = catchingObjCException(fallback: ()) { do { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift index 70c9d6530f..84a88642b8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift @@ -5,7 +5,7 @@ final class TextInputProbeIssues { var count = 0 } -enum TextInputProbeFailure: String { +enum TextInputProbeFailure: String, Error { case recordedIssue = "text_input_probe_recorded_issue" case exception = "text_input_probe_exception" } @@ -38,7 +38,60 @@ extension RunnerTests { } } + /// The input `coordinateTapTextInputAt` finds, with the identity it carried then, read inside the + /// same issue containment. Nil when the probe finds none or cannot answer. + func coordinateTapTextInputIdentityAt(app: XCUIApplication, x: Double, y: Double) -> TextInputAtPoint? { + let probed = containingTextInputProbeIssues(fallback: nil) { shouldStop -> TextInputAtPoint? in + guard let element = queryTextInputs(app: app, point: CGPoint(x: x, y: y), shouldStop: shouldStop).first, + case .input(let identity) = probeTextEntryInput(element) + else { + return nil + } + return TextInputAtPoint(element: element, identity: identity) + } + guard case .success(let input) = probed else { return nil } + return input + } + + /// Whether `input`'s handle still resolves to the input it named when found, read inside the + /// same issue containment. A read that cannot answer counts as no. + func textInputStillResolves(_ input: TextInputAtPoint) -> Bool { + let probed = containingTextInputProbeIssues(fallback: false) { _ in + input.element.exists && probeTextEntryInput(input.element) == .input(input.identity) + } + guard case .success(let resolves) = probed else { return false } + return resolves + } + + /// What `target`'s input reads between a replacement's clear passes, read inside the same issue + /// containment, with a placeholder read as empty. A read that cannot answer is `.unavailable`. + func clearedFieldRead(app: XCUIApplication, target: TextEntryTarget) -> ClearedFieldRead { + let probed = containingTextInputProbeIssues(fallback: ClearedFieldRead.unavailable) { _ in + guard let input = resolveTextEntryElement(app: app, target: target) else { return .unavailable } + if input.elementType == .secureTextField { return .unreadable } + return editableTextValue(for: input, treatingPlaceholderAsEmpty: true).map { .text($0) } ?? .unavailable + } + guard case .success(let read) = probed else { return .unavailable } + return read + } + func probeTextInputs(app: XCUIApplication, point: CGPoint) -> TextInputProbeOutcome { + switch containingTextInputProbeIssues(fallback: [], { shouldStop in + queryTextInputs(app: app, point: point, shouldStop: shouldStop) + }) { + case .success(let elements): + return elements.isEmpty ? .absent : .matches(elements) + case .failure(let failure): + return .unavailable(failure) + } + } + + /// Runs an optional probe whose XCTest issues are contained instead of recorded: an issue makes + /// the probe unavailable rather than failing the command and invalidating the runner. + private func containingTextInputProbeIssues( + fallback: T, + _ probe: (_ shouldStop: () -> Bool) -> T + ) -> Result { precondition(Thread.isMainThread) let issues = TextInputProbeIssues() suppressedIssueLock.lock() @@ -50,12 +103,12 @@ extension RunnerTests { textInputProbeIssues = previous suppressedIssueLock.unlock() } - let (elements, exception) = catchingObjCException(fallback: []) { - queryTextInputs(app: app, point: point, shouldStop: { self.hasTextInputProbeIssues(issues) }) + let (value, exception) = catchingObjCException(fallback: fallback) { + probe({ self.hasTextInputProbeIssues(issues) }) } - if hasTextInputProbeIssues(issues) { return .unavailable(.recordedIssue) } - if exception != nil { return .unavailable(.exception) } - return elements.isEmpty ? .absent : .matches(elements) + if hasTextInputProbeIssues(issues) { return .failure(.recordedIssue) } + if exception != nil { return .failure(.exception) } + return .success(value) } private func hasTextInputProbeIssues(_ scope: TextInputProbeIssues) -> Bool { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift index 939f92c122..2e84a4d09c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift @@ -27,6 +27,8 @@ extension RunnerTests { hasY: command.y != nil, xCTestChannelPenalized: xCTestChannelPenalized ), let x = command.x, let y = command.y { + // Resolved before the tap: the focus it causes can move the field away from this point. + let inputAtPoint = coordinateTapTextInputIdentityAt(app: activeApp, x: x, y: y) let policyKind = SynthesizedGesturePolicyKind.coordinateTap let context = synthesizedCoordinateContext( app: activeApp, @@ -40,7 +42,8 @@ extension RunnerTests { resolvedCoordinateTarget = TextEntryTarget( element: nil, refreshPoint: CGPoint(x: x, y: y), - prefersFocusedElement: false + prefersFocusedElement: false, + inputAtRefreshPoint: inputAtPoint ) case .xctestFallback: break diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift index 8ee82d7b31..8fd938aa23 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift @@ -155,5 +155,256 @@ extension RunnerTests { XCTAssertEqual(response.error?.code, "TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED") XCTAssertEqual(String(describing: textField.value ?? ""), "") } + + /// Drops the select-all of the first `remaining` replacing posts, the way a React Native field in + /// a freshly launched app does, so each of those passes deletes only the last character. + final class SelectAllDroppingSynthesizer: TextEntrySynthesizing { + var remaining: Int + + init(dropping count: Int) { + remaining = count + } + + func enterText( + app: XCUIApplication, + text: String, + replacingExistingText: Bool + ) -> SynthesizedTextEntryAction { + let dropsSelectAll = replacingExistingText && remaining > 0 + if dropsSelectAll { remaining -= 1 } + return PrivateXCTestTextEntrySynthesizer().enterText( + app: app, + text: text, + replacingExistingText: replacingExistingText && !dropsSelectAll + ) + } + } + + /// Two fixed clear passes left "Clie" of "Client" in 3 of 5 fills on a freshly launched React + /// Native app that dropped its select-alls, and the fill typed after it. The clear now repeats + /// until the field reads empty, and types nothing over text it could not remove. + @MainActor + func testSynthesizedReplacementClearsPastDroppedSelectAlls() throws { + let field = try focusSynthesizedReplacementField() + defer { tearDownSynthesizedReplacementField() } + let frame = field.frame + let found = try XCTUnwrap(coordinateTapTextInputIdentityAt(app: app, x: frame.midX, y: frame.midY)) + let cases: [(dropped: Int, failure: TextEntryFailure?)] = [ + (2, nil), + (SynthesizedTextPlan.Step.maxClearPassCount, .clearNotObserved), + ] + + for testCase in cases { + let label = "\(testCase.dropped) select-alls dropped" + let seeded = try replaceSynthesizedFieldText(field, text: "stale", commandId: "seed-\(testCase.dropped)") + XCTAssertTrue(seeded.ok, String(describing: seeded.error)) + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: nil, + refreshPoint: CGPoint(x: frame.midX, y: frame.midY), + prefersFocusedElement: false, + inputAtRefreshPoint: found + ), + text: "fresh", + delaySeconds: 0, + repairMode: .replacement, + xCTestChannelPenalized: true, + synthesizer: SelectAllDroppingSynthesizer(dropping: testCase.dropped) + ) + + let value = String(describing: field.value ?? "") + XCTAssertEqual(result.failure, testCase.failure, label) + if testCase.failure == nil { + XCTAssertEqual(value, "fresh", label) + } else { + XCTAssertFalse(value.contains("fresh"), "typed over text it could not clear: \(value)") + XCTAssertFalse(value.isEmpty, label) + } + } + } + + /// Launches the fixture whose field moves away when focused and leaves a neighbouring field under + /// the point the focus tap hit, the way a React Native bottom sheet extends above the keyboard. + /// The field is not focused yet, so the replacement's own tap starts the move. + @MainActor + func launchFieldThatMovesOnFocus() throws -> (field: XCUIElement, neighbour: XCUIElement) { + app.launchArguments = ["--agent-device-text-entry-regression", "--agent-device-text-entry-moves-on-focus"] + app.launch() + XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout)) + let field = app.textFields["agent-device-hardware-keyboard-input"] + let neighbour = app.textFields["agent-device-text-entry-neighbour"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + XCTAssertTrue(neighbour.waitForExistence(timeout: appExistenceTimeout)) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + penalizeSnapshotXCTestChannel(bundleId: nil, reason: "test") + return (field, neighbour) + } + + /// The commit wait used to re-read whatever field sat under the pre-focus point, which after the + /// move is the neighbour, so a replacement that landed was reported as + /// TEXT_INPUT_COMMIT_NOT_OBSERVED. + @MainActor + func testSynthesizedReplacementConfirmsAFieldThatMovedOnFocus() throws { + let (field, neighbour) = try launchFieldThatMovesOnFocus() + defer { tearDownSynthesizedReplacementField() } + let pointBeforeFocus = field.frame + + let response = try replaceSynthesizedFieldText(field, text: "fresh", commandId: "fill-moved-field") + + XCTAssertNotEqual(field.frame.midY, pointBeforeFocus.midY, "the fixture field did not move") + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertEqual(String(describing: field.value ?? ""), "fresh") + XCTAssertEqual(String(describing: neighbour.value ?? ""), "neighbour") + } + + /// `fill ""` used to clear whatever field the move left under the pre-focus point, and report + /// success because that field was then empty. + @MainActor + func testSynthesizedClearEmptiesTheFieldThatMovedOnFocus() throws { + let (field, neighbour) = try launchFieldThatMovesOnFocus() + defer { tearDownSynthesizedReplacementField() } + + let response = try replaceSynthesizedFieldText(field, text: "", commandId: "clear-moved-field") + + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertEqual(String(describing: field.value ?? ""), "") + XCTAssertEqual(String(describing: neighbour.value ?? ""), "neighbour") + } + + /// Once the input under the point was found before the tap, the point no longer names the field. + /// A handle that re-bound to another input (one inserted or reordered ahead of it) or no longer + /// resolves used to fall through to that input, or to whatever sat under the point, and `fill ""` + /// cleared it. Without an identifier, as on a React Native input with no testID, nothing else can + /// name the input, so the clear fails closed. + @MainActor + func testSynthesizedClearFailsClosedWhenTheInputFoundUnderThePointIsGone() throws { + let (field, neighbour) = try launchFieldThatMovesOnFocus() + defer { tearDownSynthesizedReplacementField() } + let rebound = app.textFields.element(boundBy: 1) + XCTAssertEqual(rebound.identifier, "agent-device-text-entry-neighbour") + let neighbourFrame = neighbour.frame + let found = [ + "re-bound": TextInputAtPoint(element: rebound, identity: TextEntryInputIdentity(elementType: .textField, identifier: "")), + "missing": TextInputAtPoint( + element: app.textFields["agent-device-gone"], + identity: TextEntryInputIdentity(elementType: .textField, identifier: "agent-device-gone") + ), + ] + + for (handle, inputAtRefreshPoint) in found { + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: nil, + refreshPoint: CGPoint(x: neighbourFrame.midX, y: neighbourFrame.midY), + prefersFocusedElement: false, + inputAtRefreshPoint: inputAtRefreshPoint + ), + text: "", + delaySeconds: 0, + repairMode: .replacement, + xCTestChannelPenalized: true, + synthesizer: RecordingTextEntrySynthesizer() + ) + + XCTAssertEqual(result.failure, .notFocused, handle) + XCTAssertEqual(String(describing: neighbour.value ?? ""), "neighbour", handle) + XCTAssertEqual(String(describing: field.value ?? ""), "stale", handle) + } + } + + /// An input found with an identifier stays reachable after its handle re-binds: the bound + /// identity finds it app-wide, so `fill ""` clears it, and never the input the handle now names + /// or the one under the point. + @MainActor + func testSynthesizedClearFindsAnIdentifiedInputWhoseHandleReBound() throws { + let (field, neighbour) = try launchFieldThatMovesOnFocus() + defer { tearDownSynthesizedReplacementField() } + let rebound = app.textFields.element(boundBy: 1) + XCTAssertEqual(rebound.identifier, "agent-device-text-entry-neighbour") + let neighbourFrame = neighbour.frame + + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: nil, + refreshPoint: CGPoint(x: neighbourFrame.midX, y: neighbourFrame.midY), + prefersFocusedElement: false, + inputAtRefreshPoint: TextInputAtPoint( + element: rebound, + identity: TextEntryInputIdentity(elementType: .textField, identifier: field.identifier) + ) + ), + text: "", + delaySeconds: 0, + repairMode: .replacement, + xCTestChannelPenalized: true, + synthesizer: RecordingTextEntrySynthesizer() + ) + + XCTAssertNil(result.failure) + XCTAssertEqual(String(describing: field.value ?? ""), "") + XCTAssertEqual(String(describing: neighbour.value ?? ""), "neighbour") + } + + /// Checking that the found input still resolves reads its handle again on the penalized channel. + /// A read that cannot answer used to record the failure, which fails the command and ends the + /// runner. The handle here matches both inputs, so its snapshot cannot answer, and the input has + /// no identifier to be found by instead. + @MainActor + func testSynthesizedClearFailsClosedWhenTheInputFoundUnderThePointCannotBeRead() throws { + let (field, neighbour) = try launchFieldThatMovesOnFocus() + defer { tearDownSynthesizedReplacementField() } + let neighbourFrame = neighbour.frame + let failures = currentXCTestFailureCount() + + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: nil, + refreshPoint: CGPoint(x: neighbourFrame.midX, y: neighbourFrame.midY), + prefersFocusedElement: false, + inputAtRefreshPoint: TextInputAtPoint( + element: app.textFields.element, + identity: TextEntryInputIdentity(elementType: .textField, identifier: "") + ) + ), + text: "", + delaySeconds: 0, + repairMode: .replacement, + xCTestChannelPenalized: true, + synthesizer: RecordingTextEntrySynthesizer() + ) + + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + XCTAssertEqual(result.failure, .notFocused) + XCTAssertEqual(String(describing: neighbour.value ?? ""), "neighbour") + XCTAssertEqual(String(describing: field.value ?? ""), "stale") + } + + /// The pre-tap lookup runs on a channel already penalized for failing XCTest reads. A lookup that + /// fails leaves the fill to the point; it used to record the failure, which fails the command + /// and ends the runner. + @MainActor + func testSynthesizedReplacementContainsAFailedLookupOfTheInputUnderThePoint() throws { + let textField = try focusSynthesizedReplacementField() + defer { + textInputProbeIssueForTesting = nil + tearDownSynthesizedReplacementField() + } + textInputProbeIssueForTesting = XCTIssue( + type: .assertionFailure, + compactDescription: "Injected pre-tap text input query failure" + ) + + let response = try replaceSynthesizedFieldText(textField, text: "fresh", commandId: "fill-lookup-failed") + + XCTAssertNil(textInputProbeIssueForTesting, "the fill made no lookup") + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertEqual(String(describing: textField.value ?? ""), "fresh") + } #endif } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift index 0f5b7a86c1..586347eabc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift @@ -181,6 +181,32 @@ extension RunnerTests { XCTAssertEqual(observations, 0, "no post-dispatch read can resolve this collision") } + // A secure field reads nil on every poll, so the wait could only expire: every penalized-route + // password `fill` failed with TEXT_INPUT_COMMIT_NOT_OBSERVED. It is left unverified instead, as + // the element route leaves it, also when the text equals its placeholder; an ordinary field + // that reads nil, or whose text equals its placeholder, still fails. + func testSynthesizedReplacementCommitLeavesASecureFieldUnverified() { + for placeholder in [nil, "hunter2"] { + for fieldIsSecure in [true, false] { + let label = "secure: \(fieldIsSecure), placeholder: \(placeholder ?? "none")" + var observations = 0 + let outcome = Self.awaitSynthesizedReplacementCommitOutcome( + expectedText: "hunter2", + placeholder: placeholder, + fieldIsSecure: fieldIsSecure, + stallBudget: 0, + observe: { + observations += 1 + return nil + }, + waitForNextObservation: {} + ) + XCTAssertEqual(outcome, fieldIsSecure ? .unobservable : .notObserved, label) + XCTAssertEqual(observations, fieldIsSecure || placeholder != nil ? 0 : 1, label) + } + } + } + // The mapping the command actually refuses on. `.unobservable` must stay a success: it is the // contract for submit-key text, so inverting it would fail every `fill` ending in a submit key. func testOnlyAnUnobservedCommitBecomesACommandFailure() { @@ -283,20 +309,55 @@ extension RunnerTests { ) } - // A select-and-type post runs two synthesize records — the Command-A selection and the text — so a - // burst that replaces costs more than the characters it types. Charging one call per post is the - // projection defect this plan exists to remove, one level down. - func testSynthesizedPlanChargesAReplacingPostTwoSynthesizeCalls() { + // A replacing post may clear up to four times — a select-all record, a delete-key record and a + // read of the field each, because a freshly launched app drops select-alls — before its text + // record, so it is charged nine synthesize records, four keystrokes and four reads more than the + // characters it types. Charging one call per post is the projection defect this plan exists to + // remove, one level down. + func testSynthesizedPlanChargesAReplacingPostItsClears() { let replacing = SynthesizedTextPlan.Step(characterCount: 1, replacesExistingText: true) - XCTAssertEqual(replacing.synthesizeCallCount, 2) + XCTAssertEqual(replacing.synthesizeCallCount, 9) + XCTAssertEqual(replacing.keystrokeCount, 5) + XCTAssertEqual(replacing.clearReadCount, 4) XCTAssertEqual( SynthesizedTextPlan.Step(characterCount: 1).synthesizeCallCount, 1 ) XCTAssertEqual( Self.synthesizedTextPlan(characterCount: 1, delaySeconds: 0, selectsExistingText: true).seconds, - TextEntryTiming.synthesizedCharacterInterval - + 2 * TextEntryTiming.synthesizeCallOverhead + 5 * TextEntryTiming.synthesizedCharacterInterval + + 9 * TextEntryTiming.synthesizeCallOverhead + + 4 * TextEntryTiming.synthesizedClearReadAllowance + ) + } + + // Each pass's select-all can be dropped, deleting only the last character, so the clear repeats + // until the field reads empty, and types nothing over text it could not remove or could not read. + // A field that never exposes its value gets the unread passes and is typed into unverified. + func testSynthesizedClearRepeatsUntilTheFieldReadsEmpty() { + let cases: [(reads: [ClearedFieldRead], outcome: SynthesizedClearOutcome, passes: Int)] = [ + ([.text("")], .cleared, 1), + ([.text("Clien"), .text("Clie"), .text("")], .cleared, 3), + ([.text("Clien"), .text("Clie"), .text("Cli"), .text("Cl")], .notCleared, 4), + ([.text("Clien"), .unavailable], .notCleared, 2), + ([.unreadable, .unreadable], .unverified, 2), + ] + for testCase in cases { + var passes = 0 + var reads = testCase.reads[...] + let outcome = Self.clearForSynthesizedReplacement( + clearOnce: { + passes += 1 + return .posted + }, + read: { reads.popFirst() ?? .unavailable } + ) + XCTAssertEqual(outcome, testCase.outcome, "\(testCase.reads)") + XCTAssertEqual(passes, testCase.passes, "\(testCase.reads)") + } + XCTAssertEqual( + Self.clearForSynthesizedReplacement(clearOnce: { .stop }, read: { .text("") }), + .stopped ) } @@ -535,25 +596,18 @@ extension RunnerTests { #if os(iOS) @MainActor - func testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller() { + func testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller() throws { + let field = try focusSynthesizedReplacementField() + defer { tearDownSynthesizedReplacementField() } let synthesizer = RecordingTextEntrySynthesizer() - // Springboard, not a bare `XCUIApplication()`: the commit wait now really polls (see below), - // and each poll resolves `target.refreshPoint` through `textInputAt`, which queries the real - // XCTest element-query channel. Against a bare, never-`.launch()`ed `XCUIApplication()` that - // query throws `_XCTestCaseInterruptionException` ("Application ... is not running") on every - // single poll — caught by `safely(...)` so production code never sees it, but XCTest's own - // instrumentation independently records each occurrence as a test failure regardless, which - // faked this test red under `xcodebuild test-without-building` despite every assertion below - // passing (verified locally: 15 recorded failures, 0 of them from an XCTAssert). Springboard is - // always running on a booted simulator without an explicit launch, so the same query instead - // resolves normally to zero matching elements — this is not a workaround for a flaky query, it - // is giving the query a fixture it can actually answer. + let frame = field.frame let result = typeTextReliably( - app: springboard, + app: app, target: TextEntryTarget( element: nil, - refreshPoint: CGPoint(x: 10, y: 20), - prefersFocusedElement: false + refreshPoint: CGPoint(x: frame.midX, y: frame.midY), + prefersFocusedElement: false, + inputAtRefreshPoint: try XCTUnwrap(coordinateTapTextInputIdentityAt(app: app, x: frame.midX, y: frame.midY)) ), text: "abc", delaySeconds: 0.001, @@ -565,7 +619,8 @@ extension RunnerTests { XCTAssertEqual( synthesizer.posts, [ - RecordingTextEntrySynthesizer.Post(text: "a", replacesExistingText: true), + RecordingTextEntrySynthesizer.Post(text: XCUIKeyboardKey.delete.rawValue, replacesExistingText: true), + RecordingTextEntrySynthesizer.Post(text: "a", replacesExistingText: false), RecordingTextEntrySynthesizer.Post(text: "b", replacesExistingText: false), RecordingTextEntrySynthesizer.Post(text: "c", replacesExistingText: false), ] @@ -575,21 +630,46 @@ extension RunnerTests { XCTAssertEqual(result.textEntryRoute, "synthesized-first-responder-replacement") // The regression this pins: this route used to return here with no commit wait at all, so a // dropped or still-in-flight character was indistinguishable from success (the "ada@example" - // landing as "aexample" CI signature). The fake synthesizer never actually writes into - // Springboard, so the wait's `observe()` reads nil (no matching field at that point) on every - // poll and the value never becomes "abc" — under the replacement-mode outcome function that is - // correctly a failure (see `testSynthesizedReplacementCommitCatchesMiddleRunMissingFromTheField` - // for why it must NOT be waved through as success), so this call runs the real 3-second deadline - // (`TextEntryTiming.synthesizedCommitStallTimeout`; a nil read never advances the expected - // prefix, so `SynthesizedCommitDeadline` grants it no extra time) before returning. That is - // deliberate here, not a flake: this test only runs in the nightly XCUITest lane (see - // `runner-xctest-local-run-gotchas` memory / ios.yml's `-only-testing:` allowlist), where a - // few extra seconds is a non-issue, and the alternative — asserting `nil` on a wiring path - // that can never actually observe the expected text — would silently reintroduce the exact - // bug this fix closes. + // landing as "aexample" CI signature). The fake synthesizer never writes into the field, so the + // wait reads "" on every poll and the value never becomes "abc" — under the replacement-mode + // outcome function that is correctly a failure (see + // `testSynthesizedReplacementCommitCatchesMiddleRunMissingFromTheField` for why it must NOT be + // waved through as success), so this call runs the real 3-second deadline + // (`TextEntryTiming.synthesizedCommitStallTimeout`) before returning. XCTAssertEqual(result.failure, .commitNotObserved) } + // A replacement whose clear cannot read the field back cannot tell whether the old text is gone, + // so it types nothing: it used to type over whatever was left. Springboard, not a bare + // `XCUIApplication()`: against a never-`.launch()`ed app every element query throws + // `_XCTestCaseInterruptionException`, which XCTest records as a test failure even when production + // code catches it. Springboard is always running, so the query resolves to no input at the point. + @MainActor + func testSynthesizedReplacementTypesNothingWhenItCannotReadTheFieldBack() { + let synthesizer = RecordingTextEntrySynthesizer() + let failures = currentXCTestFailureCount() + let result = typeTextReliably( + app: springboard, + target: TextEntryTarget( + element: nil, + refreshPoint: CGPoint(x: 10, y: 20), + prefersFocusedElement: false + ), + text: "abc", + delaySeconds: 0, + repairMode: .replacement, + xCTestChannelPenalized: true, + synthesizer: synthesizer + ) + + XCTAssertEqual( + synthesizer.posts, + [RecordingTextEntrySynthesizer.Post(text: XCUIKeyboardKey.delete.rawValue, replacesExistingText: true)] + ) + XCTAssertEqual(result.failure, .clearNotObserved) + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + } + // `fill ""` is the clear-field primitive (#2063). When no clear target resolves — // Springboard's home screen has no focused text input — the empty-text replacement path must // fail closed: it used to fall through to the vacuous-typing early return and report diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift index 8eade0870f..cbeaee172a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift @@ -337,10 +337,20 @@ extension RunnerTests { XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error)) try skipUnlessSoftwareKeyboardIsVisible() - let text = String( - repeating: "x", - count: SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0) + 1 - ) + // The shortest append the budget refuses. `maxTextLength` answers for a replacement, whose + // clears cost more than an append's warmup split, so it names a shorter text than this one. + var count = 1 + while !SynthesizedDeliveryBudget.exceeds( + Self.synthesizedTextPlan( + characterCount: count, + delaySeconds: 0, + selectsExistingText: false, + peelsWarmupCharacter: true + ) + ) { + count += 1 + } + let text = String(repeating: "x", count: count) let failureCountBefore = currentXCTestFailureCount() // The target the `type` command builds when it cannot resolve an input but the keyboard is up: // no element, no refresh point, focused-element preference. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 82cec170ef..8a38e44971 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -514,6 +514,7 @@ On a WebDriver-backed device cloud, a request whose driver does not implement it If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. On iOS, if `type "\n"` reports `TEXT_INPUT_SYNTHESIS_UNAVAILABLE` after tapping a field while the software keyboard is hidden, show the software keyboard, then retry. The runner reports this error instead of risking input through an unreliable text-entry path. On iOS, if `fill` reports `TEXT_INPUT_COMMIT_NOT_OBSERVED`, the runner could not confirm the typed text reached the field — either it did not land before the runner's deadline, or the expected final text is identical to the field's placeholder. In the latter case, accessibility cannot distinguish committed text from an empty field rendering that placeholder, even if the field held content before dispatch. The field may hold none, part, or all of the text: run `snapshot -i` and inspect it. If it already matches, continue; otherwise retry with the full text quoted and `fill --delay-ms 80`, which replaces the whole value. Do not use `type`, which appends to whatever committed. This covers the coordinate-driven `fill` route taken when the accessibility channel is under load, which observes the field after synthesizing; it is not a guarantee that every text-entry route verifies its result. +On iOS, if `fill` reports `TEXT_INPUT_CLEAR_NOT_OBSERVED`, the same coordinate-driven route could not see the field's old text removed before typing, so it typed nothing. The field may still hold some or all of its old text: run `snapshot -i` to inspect it, then retry `fill`. Do not use `type`, which appends to whatever the field holds. On iOS, if `fill` reports `TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED`, the text is longer than that coordinate-driven route can type inside one runner command at its bounded pace, and nothing was typed. Fill at most the character limit the hint names for your `--delay-ms`, and append the rest with separate `type` commands: the hint gives one limit without `--delay-ms` and a lower one for the delay it recommends. `--delay-ms` lowers the budget because each character then gets its own synthesize call and each gap between characters pays that delay; a longer timeout does not help. Use plain `fill` or `type` first for ordinary login and form fields. Use `--delay-ms` on `type` or `fill` only when a debounced search field or search-as-you-type input actually misses characters, or when the app must receive incremental updates. Delayed typing intentionally prefers paced character entry over clipboard-style fallbacks so the target field receives each incremental update.