diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index b9cd0b9005..4d70b4273c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -65,6 +65,15 @@ @interface AgentDeviceRunnerViewController : UIViewController @property(nonatomic, strong) UILabel *alertActivationBusyAnswer; @property(nonatomic, assign) NSUInteger firstAlertActions; @property(nonatomic, assign) NSUInteger replacementAlertActions; +@property(nonatomic, strong) UILabel *textEntryWriteBackStatus; +@property(nonatomic, assign) NSUInteger textEntryRenderedEdits; +@property(nonatomic, assign) NSUInteger textEntryWriteBacks; +@property(nonatomic, copy, nullable) NSString *textEntryRenderedValue; +@property(nonatomic, assign) NSTimeInterval textEntryLastEditTime; +@property(nonatomic, assign) NSTimeInterval textEntryBurstStartTime; +@property(nonatomic, assign) NSUInteger textEntryBurstEdits; +@property(nonatomic, assign) NSTimeInterval textEntryBurstMinGap; +@property(nonatomic, assign) NSTimeInterval textEntryAcknowledgeWindowSeconds; @property(nonatomic, assign) BOOL alertFixtureStarted; @property(nonatomic, strong) NSTimer *alertActivationBusyBackstop; @property(nonatomic, strong) NSTimer *alertBannerRepost; @@ -175,6 +184,17 @@ - (void)updateAlertActionStatus { (unsigned long)self.replacementAlertActions]; } +- (void)updateTextEntryWriteBackStatus { + NSTimeInterval burstSpan = self.textEntryLastEditTime - self.textEntryBurstStartTime; + self.textEntryWriteBackStatus.text = [NSString + stringWithFormat:@"edits=%lu write-backs=%lu burst-edits=%lu burst-ms=%lu min-gap-ms=%lu", + (unsigned long)self.textEntryRenderedEdits, + (unsigned long)self.textEntryWriteBacks, + (unsigned long)self.textEntryBurstEdits, + (unsigned long)llround(burstSpan * 1000), + (unsigned long)llround(self.textEntryBurstMinGap * 1000)]; +} + - (void)presentAlertFixtureReplacement:(BOOL)replacement { NSArray *arguments = NSProcessInfo.processInfo.arguments; BOOL sameTitle = [arguments containsObject:@"--agent-device-alert-same-title"]; @@ -232,7 +252,51 @@ - (void)viewDidAppear:(BOOL)animated { } #endif +// How fast an app that owns this field's value can acknowledge edits: one render per window, passed +// by the test as `--agent-device-text-entry-acknowledge-window `. An edit that arrives +// inside that window overtook the render still in flight, so the value that render commits predates +// it and writing it erases the characters that got ahead of the app. The app then reads its own +// erasure back into its model, which is why the field stays wrong instead of healing when the burst +// finishes. The window is decided at the edit rather than scheduled, so a loaded host, which +// stretches the gaps between characters, can only make this app keep up better. +static NSTimeInterval AgentDeviceTextEntryAcknowledgeWindow(void) { + NSArray *arguments = NSProcessInfo.processInfo.arguments; + NSUInteger index = [arguments indexOfObject:@"--agent-device-text-entry-acknowledge-window"]; + return index == NSNotFound || index + 1 >= arguments.count ? 0 : [arguments[index + 1] doubleValue]; +} + +// 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; + - (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 + // flight, and the field settles stable short of the request. + if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) { + NSTimeInterval now = NSProcessInfo.processInfo.systemUptime; + NSTimeInterval gap = now - self.textEntryLastEditTime; + BOOL overtookARender = self.textEntryRenderedValue != nil && gap < self.textEntryAcknowledgeWindowSeconds; + if (self.textEntryBurstEdits == 0 || gap > AgentDeviceTextEntryBurstBreakSeconds) { + self.textEntryBurstStartTime = now; + self.textEntryBurstEdits = 0; + self.textEntryBurstMinGap = 0; + } else if (self.textEntryBurstEdits == 1 || gap < self.textEntryBurstMinGap) { + self.textEntryBurstMinGap = gap; + } + self.textEntryBurstEdits += 1; + self.textEntryLastEditTime = now; + if (overtookARender) { + if (![textField.text isEqualToString:self.textEntryRenderedValue]) { + textField.text = self.textEntryRenderedValue; + self.textEntryWriteBacks += 1; + } + } else { + self.textEntryRenderedValue = [textField.text copy]; + self.textEntryRenderedEdits += 1; + } + [self updateTextEntryWriteBackStatus]; + } if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-disappear-after-input"] && textField.text.length > 0) { [textField removeFromSuperview]; @@ -281,7 +345,13 @@ - (void)viewDidLoad { UITextField *textField = [[UITextField alloc] init]; textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input"; textField.borderStyle = UITextBorderStyleRoundedRect; - textField.inputView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 1)]; + // 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 + // view in place, so a lane test can reach the branch that requires a visible keyboard. + if (![NSProcessInfo.processInfo.arguments + containsObject:@"--agent-device-text-entry-soft-keyboard"]) { + textField.inputView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 1)]; + } [textField addTarget:self action:@selector(agentDeviceTextEntryDidChange:) forControlEvents:UIControlEventEditingChanged]; @@ -293,6 +363,21 @@ - (void)viewDidLoad { [textField.widthAnchor constraintEqualToConstant:240], [textField.heightAnchor constraintEqualToConstant:44], ]]; + 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 + // character overtook one, so a lane test can tell a burst the app kept up with from an inert + // fixture. Counts only: no field content crosses into the test. + self.textEntryWriteBackStatus = [[UILabel alloc] init]; + self.textEntryWriteBackStatus.accessibilityIdentifier = @"agent-device-text-entry-write-backs"; + self.textEntryWriteBackStatus.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.textEntryWriteBackStatus]; + [NSLayoutConstraint activateConstraints:@[ + [self.textEntryWriteBackStatus.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.textEntryWriteBackStatus.topAnchor constraintEqualToAnchor:textField.bottomAnchor constant:12], + ]]; + [self updateTextEntryWriteBackStatus]; + } } if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-crowded-screen"]) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h index 668e270b04..cecbe07ed8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h @@ -17,13 +17,19 @@ typedef NS_ENUM(NSInteger, RunnerSynthesizedTextEntryStatus) { @interface RunnerSynthesizedTextEntry : NSObject +// Characters per second the synthesized text-input records are typed at. Declared here, where the +// typing happens, so the delivery budget that bounds a burst is charged the same pace the app sees. +// The edit-acknowledge window that pace is sized for is a separate assumption about the app +// (TextEntryTiming.synthesizedAcknowledgeWindowSeconds), not a value derived from this one. ++ (NSUInteger)typingSpeedCharactersPerSecond; + // Synthesizes keyboard input for the current first responder without resolving an // XCUIElement or serializing the application's accessibility tree. + (RunnerSynthesizedTextEntryResult *)synthesizeTextWithApplication:(id)application text:(NSString *)text; -// Replaces the current first responder's contents using one synthesized -// Command-A, Delete, and text-input event sequence. +// Replaces the current first responder's contents with one synthesized Command-A record +// followed by a text-input record, typed at the bounded pace declared in the implementation. + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application text:(NSString *)text; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m index bae3d7f0fe..d76deeb4eb 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m @@ -5,6 +5,20 @@ static NSString *const RunnerTextSynthesisSurface = @"text"; +// XCTest's `typingSpeed:` argument is characters per second. At 60 the 11 characters of a `fill` +// arrived at a fixture field across 131 ms (~13 ms per gap), which is faster than an app that owns +// its field's value and re-applies it after the edit (a controlled React Native `TextInput`, an +// async validator) can acknowledge: such a write lands between two characters of the burst and +// erases what was typed while it was in flight, leaving a value that is stable short of the +// request. 12 characters/second spaces them ~83 ms apart on average, which reduces that loss but +// does not remove it: XCTest does not space the characters evenly, and two of them can reach the +// app a few milliseconds apart. Against a fixture app that acknowledges each edit within 40 ms, 60 +// characters/second left 1 of 11 characters in 20 of 20 bursts, and this pace left 10 or 11. The +// command refuses a field left short; back-pressure from the field (#2906) is what would prevent +// it. The app-owned-value lane test pins the average spacing the app sees, and the delivery budget +// in TextEntryTiming bounds what the pace costs a long text. +static const NSUInteger RunnerTextEntryTypingSpeedCharactersPerSecond = 12; + typedef id (*RunnerTextMsgSendInit)(id, SEL, NSString *); typedef id (*RunnerTextMsgSendInitPath)(id, SEL); typedef void (*RunnerTextMsgSendType)(id, SEL, NSString *, NSTimeInterval, NSUInteger, BOOL); @@ -51,6 +65,10 @@ @implementation RunnerSynthesizedTextEntryResult @implementation RunnerSynthesizedTextEntry ++ (NSUInteger)typingSpeedCharactersPerSecond { + return RunnerTextEntryTypingSpeedCharactersPerSecond; +} + + (RunnerSynthesizedTextEntryResult *)synthesizeTextWithApplication:(id)application text:(NSString *)text { return RunnerSynthesizeTextWithMode(application, text, NO); @@ -128,7 +146,14 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application ); } ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.core.setTargetProcessIDSelector, targetProcessID); - ((RunnerTextMsgSendType)objc_msgSend)(path, bridge.typeTextSelector, text, 0.0, 60, YES); + ((RunnerTextMsgSendType)objc_msgSend)( + path, + bridge.typeTextSelector, + text, + 0.0, + RunnerTextEntryTypingSpeedCharactersPerSecond, + YES + ); ((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.core.addPathSelector, path); NSError *error = nil; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index f58f712686..f58b463dca 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -144,7 +144,7 @@ extension RunnerTests { } return try runMainThreadWork( "command_execution", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard) @@ -247,7 +247,7 @@ extension RunnerTests { while true { let failureCountBefore = try runMainThreadWork( "recorded_failure_count", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { self.currentXCTestFailureCount() @@ -264,7 +264,7 @@ extension RunnerTests { } let recordedFailureResponse = try runMainThreadWork( "recorded_failure_count", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { self.didRecordXCTestFailure(since: failureCountBefore) @@ -274,7 +274,7 @@ extension RunnerTests { if let recordedFailureResponse { try runMainThreadWork( "target_invalidation", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { self.invalidateCachedTarget(reason: "xctest_recorded_failure") @@ -289,7 +289,7 @@ extension RunnerTests { hasRetried = true try runMainThreadWork( "target_invalidation", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { self.invalidateCachedTarget(reason: "response_unavailable") diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift index ec0b2c8faa..59ea13b9fa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift @@ -11,7 +11,7 @@ extension RunnerTests { private func executeSnapshotDispatchedOnce(command: Command) throws -> Response { let preparation: SnapshotCommandPreparation = try runMainThreadWork( "command_preparation", - timeout: mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { () -> SnapshotCommandPreparation in switch try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 0ea1c6e65e..1d1e911d71 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -82,11 +82,11 @@ extension RunnerTests { text: String, delaySeconds: Double ) -> [SynthesizedReplacementStep] { - let characters = Array(text) - guard delaySeconds > 0, characters.count > 1 else { + guard synthesizedReplacementIsSpaced(characterCount: text.count, delaySeconds: delaySeconds) + else { return [SynthesizedReplacementStep(text: text, replacesExistingText: true)] } - return characters.enumerated().map { index, character in + return Array(text).enumerated().map { index, character in SynthesizedReplacementStep( text: String(character), replacesExistingText: index == 0 @@ -94,11 +94,85 @@ extension RunnerTests { } } + /// Whether a replacement is posted one character per synthesize call, `delaySeconds` apart, + /// rather than as one burst. + static func synthesizedReplacementIsSpaced(characterCount: Int, delaySeconds: Double) -> Bool { + delaySeconds > 0 && characterCount > 1 + } + + /// What a synthesized burst costs in wall clock, and the ceiling it has to fit inside before the + /// first character is posted. `synthesizedReplacementSteps` decides how a text is posted; this + /// decides whether the runner may start posting it at all. + enum SynthesizedDeliveryBudget { + /// Seconds between two characters of one synthesized burst. + static var characterInterval: TimeInterval { + 1.0 / Double(RunnerSynthesizedTextEntry.typingSpeedCharactersPerSecond()) + } + + /// Seconds the plan spends posting: each synthesize call types its characters at the pace and + /// pays its overhead, a spaced plan sleeps `delaySeconds` between two calls, and a plan that + /// peels one character as a warmup (`typeWarmup`) pays one more call and the wait before the + /// rest is posted. That wait is one poll here because the caller that asks has no element to + /// read the warmup character back from, so `waitForWarmupValue` has no value to wait for. + static func projectedSeconds( + textLength: Int, + delaySeconds: TimeInterval, + typeWarmup: Bool = false + ) -> TimeInterval { + let spaced = synthesizedReplacementIsSpaced(characterCount: textLength, delaySeconds: delaySeconds) + let warmupSplit = typeWarmup && textLength > 1 && !spaced + let calls = spaced ? textLength : (warmupSplit ? 2 : 1) + return Double(textLength) * characterInterval + + Double(calls) * TextEntryTiming.synthesizeCallOverhead + + Double(calls - 1) * delaySeconds + + (warmupSplit ? TextEntryTiming.pollInterval : 0) + } + + static func exceeds( + textLength: Int, + delaySeconds: TimeInterval, + typeWarmup: Bool = false + ) -> Bool { + projectedSeconds(textLength: textLength, delaySeconds: delaySeconds, typeWarmup: typeWarmup) + > TextEntryTiming.synthesizedDeliveryCeiling + } + + /// Longest text `exceeds` admits at `delaySeconds`, which is what the refusal tells the caller. + static func maxTextLength(delaySeconds: TimeInterval) -> Int { + var length = 1 + while !exceeds(textLength: length + 1, delaySeconds: delaySeconds) { + length += 1 + } + return length + } + } + func runSynthesizedReplacementRoute( _ request: SynthesizedReplacementRequest ) -> SynthesizedReplacementRouteOutcome { #if os(iOS) NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement") + if SynthesizedDeliveryBudget.exceeds( + textLength: request.text.count, + delaySeconds: request.delaySeconds + ) { + NSLog( + "AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement " + + "reason=delivery-budget-refused chars=%d budgetChars=%d", + request.text.count, + SynthesizedDeliveryBudget.maxTextLength(delaySeconds: request.delaySeconds) + ) + return .completed( + TextEntryResult( + verified: nil, + repaired: false, + expectedText: request.text, + observedText: nil, + textEntryRoute: "synthesized-first-responder-replacement", + failure: .synthesisBudgetExceeded + ) + ) + } let steps = Self.synthesizedReplacementSteps( text: request.text, delaySeconds: request.delaySeconds diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index 8aed63e9a5..96f3a3c3e3 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 synthesisBudgetExceeded = "TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED" var message: String { switch self { @@ -17,6 +18,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 .synthesisBudgetExceeded: + return "The text is longer than one runner command can type at this pace." } } @@ -27,7 +30,13 @@ extension RunnerTests { case .synthesisUnavailable: 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 80. Do not use type, which appends to whatever committed." + 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 .synthesisBudgetExceeded: + let recoveryDelay = TextEntryTiming.recoveryDelayMilliseconds + let recoveryBudget = SynthesizedDeliveryBudget.maxTextLength( + delaySeconds: Double(recoveryDelay) / 1000 + ) + return "Fill at most \(SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0)) characters at a time without --delay-ms and append the rest with separate type commands, keeping each command inside that budget. --delay-ms lowers the budget, because each character then gets its own synthesize call and each gap between characters pays the delay: \(recoveryBudget) characters at --delay-ms \(recoveryDelay). A longer timeout does not help: this route is chosen when the accessibility channel is already degraded, and the pace is what makes the text long." } } } @@ -49,10 +58,31 @@ extension RunnerTests { /// Numerically the flat deadline this replaced, so a pipeline that delivers nothing is /// condemned at exactly the same instant it always was (see `SynthesizedCommitDeadline`). static let synthesizedCommitStallTimeout: TimeInterval = 3.0 - /// The commit wait's absolute bound, however long characters keep arriving. Sits well inside - /// the daemon's per-command budget (`RUNNER_COMMAND_TIMEOUT_MS`, 45s), which also has to cover - /// focus, clear and verification around this wait. + /// The commit wait's absolute bound, however long characters keep arriving. Synthesized + /// 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 + /// 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 + /// is posted; otherwise the watchdog abandons the command with the runner still typing. + static let synthesizedDeliveryCeiling: TimeInterval = RunnerTests.mainThreadExecutionTimeout + - synthesizedReplacementFocusAllowance + - synthesizedCommitCeiling + /// The edit-acknowledge window the synthesized pace is sized for: on average, a burst's + /// characters reach the app at least this far apart. XCTest spaces them unevenly, so an app + /// with this window can still lose a character that arrives early; the command then refuses the + /// short value (#2906 tracks preventing it). The pace policy test and the app-owned-value lane + /// test pin the pace against it. + static let synthesizedAcknowledgeWindowSeconds: TimeInterval = 0.04 + /// What one private synthesize call costs beyond typing its characters, which a `--delay-ms` + /// plan pays once per character. One-character calls at the shipped pace took 222 ms on average + /// on an iPhone 17 Pro simulator (212–617 ms over 235 calls), 83 ms of it the character. + static let synthesizeCallOverhead: TimeInterval = 0.15 + /// The spacing the `TEXT_INPUT_COMMIT_NOT_OBSERVED` recovery tells the caller to retry with. + static let recoveryDelayMilliseconds = 80 static let synthesizedCommitPollInterval: TimeInterval = 0.2 } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index 241d312dcb..79abf8ebf7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -162,6 +162,28 @@ extension RunnerTests { return (currentTarget, nil) } else if activeTarget.prefersFocusedElement && isKeyboardVisible(app: app) { #if os(iOS) + // Text the command budget cannot carry at the synthesized pace goes through application-wide + // typing instead. The synthesizer's pace is slowed for fields whose app owns the value, and a + // burst that long outlasts the command while the runner is still posting it. The ceiling is + // what the command's watchdog leaves, so it is charged the whole command: an append peels its + // first character for warmup and a `--delay-ms` plan dispatches one character at a time, and + // neither chunk would look long on its own. This branch's target has no element to type into, + // so nothing can be read back afterwards: the value arrives unverified, as it does for this + // route's older synthesizer-unavailable fallback. + if SynthesizedDeliveryBudget.exceeds( + textLength: text.count, + delaySeconds: delaySeconds, + typeWarmup: repairMode != .none + ) { + textEntryRoute = "xctest-application-fallback" + NSLog( + "AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=xctest-application-fallback " + + "reason=delivery-budget chars=%d", + value.count + ) + app.typeText(value) + return (resolveTextEntryElement(app: app, target: activeTarget), nil) + } textEntryRoute = "synthesized-first-responder" NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder") let action = synthesizer.enterText( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 725261ff2a..473307e802 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -68,7 +68,7 @@ final class RunnerTests: XCTestCase { // interactions clear it before it can become stale. var textEntryTapWitness: TextEntryTapWitness? let maxRequestBytes = 2 * 1024 * 1024 - let mainThreadExecutionTimeout: TimeInterval = 30 + static let mainThreadExecutionTimeout: TimeInterval = 30 let appExistenceTimeout: TimeInterval = 30 let retryCooldown: TimeInterval = 0.2 let postSnapshotInteractionDelay: TimeInterval = 0.2 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift index 0c97926342..8d2c75b439 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift @@ -448,7 +448,7 @@ extension RunnerTests { for _ in 0..<2 { try self.runMainThreadWork( "command_execution", - timeout: self.mainThreadExecutionTimeout, + timeout: Self.mainThreadExecutionTimeout, timeoutError: self.mainThreadExecutionTimeoutError ) { Thread.sleep(forTimeInterval: self.recordingFrameCaptureTimeout + 0.3) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift new file mode 100644 index 0000000000..641ee31e18 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift @@ -0,0 +1,151 @@ +import XCTest + +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + /// What the app-owned-value fixture reports about the edits it saw. Counts and timings only: the + /// field's contents never cross into the test. + struct AppOwnedFieldStatus { + let edits: Int + let writeBacks: Int + /// Edits in the latest burst, and the milliseconds between its first and last edit. + let burstEdits: Int + let burstMilliseconds: Int + let minimumGapMilliseconds: Int + } + + func appOwnedFieldStatus() throws -> AppOwnedFieldStatus { + let label = app.staticTexts["agent-device-text-entry-write-backs"].label + var fields: [String: Int] = [:] + for pair in label.split(separator: " ") { + let parts = pair.split(separator: "=") + if parts.count == 2, let value = Int(parts[1]) { fields[String(parts[0])] = value } + } + func field(_ name: String) throws -> Int { + try XCTUnwrap(fields[name], "fixture status lacks \(name): \(label)") + } + return AppOwnedFieldStatus( + edits: try field("edits"), + writeBacks: try field("write-backs"), + burstEdits: try field("burst-edits"), + burstMilliseconds: try field("burst-ms"), + minimumGapMilliseconds: try field("min-gap-ms") + ) + } + + /// Launches the text-entry fixture, focuses its field, and penalizes the XCTest channel, so a + /// coordinate replacement takes the synthesized first-responder route. + func focusSynthesizedReplacementField(extraLaunchArguments: [String] = []) throws -> XCUIElement { + app.launchArguments = ["--agent-device-text-entry-regression"] + extraLaunchArguments + app.launch() + XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout)) + let textField = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout)) + currentApp = app + currentBundleId = "com.callstack.agentdevice.runner" + currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + let focusCommand = try runnerCommandFixture( + #"{"command":"tap","commandId":"tap-replacement-field","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# + ) + let focusResponse = try executeOnMainPrepared(command: focusCommand, activeApp: app) + XCTAssertTrue(focusResponse.ok, String(describing: focusResponse.error)) + penalizeSnapshotXCTestChannel(bundleId: nil, reason: "test") + return textField + } + + func replaceSynthesizedFieldText( + _ textField: XCUIElement, + text: String, + commandId: String + ) throws -> Response { + let frame = textField.frame + // Assembled with JSONSerialization so a text carrying a quote or a backslash stays one command + // rather than invalid JSON. + let command = try JSONDecoder().decode( + Command.self, + from: JSONSerialization.data(withJSONObject: [ + "command": "type", + "commandId": commandId, + "text": text, + "textEntryMode": "replace", + "x": frame.midX, + "y": frame.midY, + ]) + ) + let failuresBeforeType = currentXCTestFailureCount() + let response = executeTypeCommand(activeApp: app, command: command) + XCTAssertFalse(didRecordXCTestFailure(since: failuresBeforeType)) + return response + } + + func tearDownSynthesizedReplacementField() { + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + + /// An app that owns its field's value renders it some time after the edit that produced it, the + /// way a controlled React Native `TextInput` does. A burst typed faster than that render has its + /// in-flight characters erased by the app's own write, which the app then reads back into its + /// model, so the field settles stable short of the request — the shape CI reported for + /// `fill id="field-email" ada@example` as `aexample` (#2080). + /// + /// Two halves, each independent of host timing: + /// - The pace: across a burst, the characters reach the app at least one acknowledge window apart + /// on average. At the pre-fix 60 characters per second they arrive about 13 ms apart and this + /// goes red. It is an average because XCTest does not space `typingSpeed:` characters evenly — + /// two of them can reach the app a few milliseconds apart at any pace — so whether an app with + /// this window keeps up with one particular burst is not something the runner can promise. + /// - The runner's: a field the app rewrote mid-burst never reports ok. An ok over a short value + /// was the original defect. + func testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow() throws { + let window = TextEntryTiming.synthesizedAcknowledgeWindowSeconds + let textField = try focusSynthesizedReplacementField(extraLaunchArguments: [ + "--agent-device-text-entry-app-owned-value", + "--agent-device-text-entry-acknowledge-window", String(window), + ]) + defer { tearDownSynthesizedReplacementField() } + + // Twice: the second replacement selects the first one's value away, which is the shape the + // reported CI trace had — a `fill` onto a field that already held text. + for commandId in ["fill-app-owned-first", "fill-app-owned-second"] { + let before = try appOwnedFieldStatus() + let response = try replaceSynthesizedFieldText(textField, text: "ada@example", commandId: commandId) + let after = try appOwnedFieldStatus() + + XCTAssertGreaterThan(after.burstEdits, 1, "the fixture saw no burst") + XCTAssertGreaterThanOrEqual( + Double(after.burstMilliseconds), + Double(after.burstEdits - 1) * window * 1000, + "\(after.burstEdits) edits reached the app in \(after.burstMilliseconds) ms " + + "(closest pair \(after.minimumGapMilliseconds) ms)" + ) + if after.writeBacks == before.writeBacks { + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertEqual(response.data?.textEntryRoute, "synthesized-first-responder-replacement") + XCTAssertEqual(String(describing: textField.value ?? ""), "ada@example") + } else { + XCTAssertFalse(response.ok, "a field the app rewrote cannot report success") + XCTAssertEqual(response.error?.code, "TEXT_INPUT_COMMIT_NOT_OBSERVED") + } + } + } + + /// A replacement the command budget cannot carry is refused before the first character is posted, + /// so a `fill` cannot end in a transport timeout that leaves the runner typing into a field nobody + /// is waiting for and the next command finding it busy. + func testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget() throws { + let textField = try focusSynthesizedReplacementField() + defer { tearDownSynthesizedReplacementField() } + + let text = String( + repeating: "x", + count: SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0) + 1 + ) + let response = try replaceSynthesizedFieldText(textField, text: text, commandId: "fill-over-budget") + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED") + XCTAssertEqual(String(describing: textField.value ?? ""), "") + } +#endif +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift index f0563c6476..a2fb1c1bf9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift @@ -91,8 +91,9 @@ extension RunnerTests { // A value with a hole in the middle is neither a matching prefix nor an exact match, and must // never settle. These are the two corruption strings actually observed in CI on `fill` // (id="field-name" "Ada Lovelace" -> "Avelace", id="field-email" "ada@example" -> "aexample"; - // first character and tail survive, a middle run is missing). - func testSynthesizedReplacementCommitCatchesDroppedMiddleCharacters() { + // first character and tail survive, a middle run is missing). The wait can refuse a value like + // this but not repair it: no later read distinguishes it from a field that has settled. + func testSynthesizedReplacementCommitCatchesMiddleRunMissingFromTheField() { let corruptions: [(expected: String, observedAfterDrop: String)] = [ (expected: "Ada Lovelace", observedAfterDrop: "Avelace"), (expected: "ada@example", observedAfterDrop: "aexample"), @@ -240,6 +241,98 @@ extension RunnerTests { ) } + // The pace is what keeps a field the app owns from losing most of a replacement (#2080), so it + // cannot drift on its own: one character interval has to leave that app at least twice the + // acknowledge window the route is sized for. The host lane runs this on every PR; the iOS lane's + // app-owned-value test checks the spacing the app actually receives. + func testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit() { + XCTAssertGreaterThanOrEqual( + SynthesizedDeliveryBudget.characterInterval, + 2 * TextEntryTiming.synthesizedAcknowledgeWindowSeconds + ) + } + + // Characters are delivered while the private synthesize call is still running, so text longer + // than the delivery ceiling would still be arriving when the main-thread watchdog abandons the + // command. The budget turns that into a refusal decided up front, at the boundary and not after + // the first character is posted. + func testSynthesizedDeliveryBudgetRefusesTextThatOutrunsTheCommand() { + let fits = SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0) + XCTAssertGreaterThan(fits, 0) + XCTAssertFalse(SynthesizedDeliveryBudget.exceeds(textLength: fits, delaySeconds: 0)) + XCTAssertTrue(SynthesizedDeliveryBudget.exceeds(textLength: fits + 1, delaySeconds: 0)) + } + + // A spaced plan posts each character in its own synthesize call and sleeps between two of them, + // so a character costs the pace, the call's overhead and the delay together, not the larger of + // pace and delay. The delay checked is the retry TEXT_INPUT_COMMIT_NOT_OBSERVED recommends. + func testSpacedDeliveryBudgetChargesEachCharacterItsCallAndDelay() { + let delay = Double(TextEntryTiming.recoveryDelayMilliseconds) / 1000 + let fits = SynthesizedDeliveryBudget.maxTextLength(delaySeconds: delay) + XCTAssertFalse(SynthesizedDeliveryBudget.exceeds(textLength: fits, delaySeconds: delay)) + XCTAssertTrue(SynthesizedDeliveryBudget.exceeds(textLength: fits + 1, delaySeconds: delay)) + XCTAssertEqual( + SynthesizedDeliveryBudget.projectedSeconds(textLength: 10, delaySeconds: delay) + - SynthesizedDeliveryBudget.projectedSeconds(textLength: 9, delaySeconds: delay), + SynthesizedDeliveryBudget.characterInterval + + TextEntryTiming.synthesizeCallOverhead + + delay, + accuracy: 1e-9 + ) + XCTAssertLessThan(fits, SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0)) + XCTAssertLessThan(SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0.2), fits) + } + + // A `type` plan peels one character as a warmup and posts the rest afterwards, so the same text + // costs one synthesize call and one wait more than the single burst the replacement route posts. + // Without this the estimate charged a burst, which is what made the over-budget branch of the + // keyboard-visible route unreachable: 215 characters looked like 1 + 214, each inside the budget. + func testTypeWarmupSplitCostsOneMoreCallThanASingleBurst() { + let length = 20 + let withWarmup = SynthesizedDeliveryBudget.projectedSeconds( + textLength: length, + delaySeconds: 0, + typeWarmup: true + ) + XCTAssertGreaterThan( + withWarmup, + SynthesizedDeliveryBudget.projectedSeconds(textLength: length, delaySeconds: 0) + ) + XCTAssertEqual( + withWarmup - SynthesizedDeliveryBudget.projectedSeconds(textLength: length, delaySeconds: 0), + TextEntryTiming.synthesizeCallOverhead + TextEntryTiming.pollInterval, + accuracy: 1e-9 + ) + // The split mirrors the plan: a spaced `type` already posts per character, and a single + // character has no rest to post. + XCTAssertEqual( + SynthesizedDeliveryBudget.projectedSeconds(textLength: length, delaySeconds: 0.2, typeWarmup: true), + SynthesizedDeliveryBudget.projectedSeconds(textLength: length, delaySeconds: 0.2) + ) + XCTAssertEqual( + SynthesizedDeliveryBudget.projectedSeconds(textLength: 1, delaySeconds: 0, typeWarmup: true), + SynthesizedDeliveryBudget.projectedSeconds(textLength: 1, delaySeconds: 0) + ) + } + + func testSynthesizedBudgetExceededCarriesItsOwnCodeAndRecovery() { + XCTAssertEqual( + TextEntryFailure.synthesisBudgetExceeded.rawValue, + "TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED" + ) + // The recovery has to tell the caller to split the text: waiting it out or raising a timeout + // does nothing, because the pace is what makes the burst long, not the host being slow. A + // delayed request fits fewer characters, so the hint names both budgets rather than promising + // the undelayed one to a caller retrying with --delay-ms. + let hint = TextEntryFailure.synthesisBudgetExceeded.hint + XCTAssertTrue(hint.contains("\(SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0)) characters at a time")) + let recoveryDelay = TextEntryTiming.recoveryDelayMilliseconds + let recoveryBudget = SynthesizedDeliveryBudget.maxTextLength( + delaySeconds: Double(recoveryDelay) / 1000 + ) + XCTAssertTrue(hint.contains("\(recoveryBudget) characters at --delay-ms \(recoveryDelay)")) + } + #if os(iOS) func testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller() { let synthesizer = RecordingTextEntrySynthesizer() @@ -284,8 +377,8 @@ extension RunnerTests { // 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 `testSynthesizedReplacementCommitCatchesDroppedMiddleCharacters` for - // why it must NOT be waved through as success), so this call runs the real 3-second deadline + // 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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift index 214f599bd6..737a53c1e1 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift @@ -148,6 +148,69 @@ extension RunnerTests { XCTAssertFalse(textField.exists) } + // Text past the delivery budget cannot be paced into a field the runner cannot resolve, so it goes + // through application-wide typing. The budget is charged the whole command, warmup split included: + // an append peels its first character for warmup, so a per-dispatch charge would find both of its + // pieces inside the budget and pace all these characters. The target carries no element by + // construction, so nothing on that route can read the value back: the command reports it + // unverified and this test reads the field itself to show every character arrived. + func testOverBudgetTypeWithoutResolvableElementTypesApplicationWide() throws { + app.launchArguments = [ + "--agent-device-text-entry-regression", + "--agent-device-text-entry-soft-keyboard", + ] + app.launch() + addTeardownBlock { [self] in + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout)) + + let textField = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout)) + let tapCommand = try runnerCommandFixture( + #"{"command":"tap","commandId":"tap-soft-keyboard-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# + ) + let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app) + XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error)) + try skipUnlessSoftwareKeyboardIsVisible() + + let text = String( + repeating: "x", + count: SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0) + 1 + ) + 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. + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: nil, + refreshPoint: nil, + prefersFocusedElement: true, + fromTapWitness: true + ), + text: text, + delaySeconds: 0, + repairMode: .append, + synthesizer: PrivateXCTestTextEntrySynthesizer() + ) + + XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) + XCTAssertNil(result.failure) + XCTAssertEqual(result.textEntryRoute, "xctest-application-fallback") + // This branch has no element to read, so the value arrives unverified and the command waited for + // nothing. The field is polled here, under its own deadline. + let valueDeadline = Date().addingTimeInterval(appExistenceTimeout) + var observed: String? + while Date() < valueDeadline { + observed = textField.value as? String + if observed == text { break } + Thread.sleep(forTimeInterval: 0.25) + } + XCTAssertEqual(observed, text) + } + private struct UnavailableTextEntrySynthesizer: TextEntrySynthesizing { func enterText( app _: XCUIApplication, @@ -189,5 +252,14 @@ extension RunnerTests { "software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path" ) } + + // The mirror precondition. A simulator with a hardware keyboard attached can keep the software + // keyboard down even for a field that has a real input view, which is an environment fact. + private func skipUnlessSoftwareKeyboardIsVisible() throws { + try XCTSkipIf( + !isKeyboardVisible(app: app), + "software keyboard is down: this simulator cannot exercise the keyboard-visible typing branch" + ) + } #endif } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift index ffa3d56d36..bdbd9b5e8c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift @@ -56,7 +56,7 @@ extension RunnerTests { box.result = result executed.fulfill() } - guard XCTWaiter.wait(for: [executed], timeout: mainThreadExecutionTimeout + 5) == .completed, + guard XCTWaiter.wait(for: [executed], timeout: Self.mainThreadExecutionTimeout + 5) == .completed, let result = box.result else { throw NSError( diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e5b2974062..2a1b443fc5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -488,6 +488,7 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and 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_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. On Android, `fill` also verifies text and treats IME-owned capture as a terminal failure instead of retrying against the wrong field.