diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index e20b95d54..6c11670e0 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -95,6 +95,7 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedTextCommitProgressWalksExpectedPrefixOnly \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testMissingBundleCommandInvalidatesCompleteCachedTargetState \ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 44ae40896..272101752 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -159,6 +159,63 @@ extension RunnerTests { repairMode == .none && fromTapWitness && !softwareKeyboardVisible } + enum SynthesizedTextCommitProgress: Equatable { + case committed + case pending + case diverged + } + + // The private synthesize call returns once the event record is posted, not once the target + // app has committed the characters, so intermediate reads walk prefix-by-prefix toward the + // expected value. Anything off that prefix path means the app transformed the input + // (formatter, mid-text caret, autocomplete) and the runner must not second-guess it. An + // unreadable value — secure field, or the element stopped resolving — ends the wait the + // same way. + static func synthesizedTextCommitProgress( + observedText: String?, + expectedText: String + ) -> SynthesizedTextCommitProgress { + guard let observedText else { + return .diverged + } + if observedText == expectedText { + return .committed + } + return expectedText.hasPrefix(observedText) ? .pending : .diverged + } + + /// Blocks until the synthesized bare-type text is observable in the target field, so `type` + /// cannot report ok while trailing characters are still uncommitted on a slow simulator. + /// + /// Observation only. A stalled prefix cannot be told apart from a suffix still queued in the + /// event stream, so re-synthesizing the difference risks committing it twice after the command + /// already reported success. Text carrying a submit key is skipped outright: the app may clear + /// or rewrite the field on submit, so `textBefore + typedText` is not the value to wait for. + func awaitSynthesizedFirstResponderCommit( + app: XCUIApplication, + target: TextEntryTarget, + textBefore: String?, + typedText: String + ) { + guard let textBefore, !typedText.contains("\n"), !typedText.contains("\r") else { + return + } + let expectedText = textBefore + typedText + let deadline = Date().addingTimeInterval(TextEntryTiming.synthesizedCommitTimeout) + while Date() < deadline { + let observedText = editableTextValue( + for: resolveTextEntryElement(app: app, target: target), + treatingPlaceholderAsEmpty: true + ) + switch Self.synthesizedTextCommitProgress(observedText: observedText, expectedText: expectedText) { + case .committed, .diverged: + return + case .pending: + sleepFor(TextEntryTiming.pollInterval) + } + } + } + static func shouldUseResolvedCoordinateTextEntryRoute( repairMode: TextTypingRepairMode, hasX: Bool, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index cd200ccb8..2de0dc186 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -40,6 +40,7 @@ extension RunnerTests { static let pollInterval: TimeInterval = 0.02 static let warmupValueTimeout: TimeInterval = 0.4 static let verificationStabilityWindow: TimeInterval = 0.2 + static let synthesizedCommitTimeout: TimeInterval = 3.0 } struct TextEntryResult { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift index 12f7a9414..464f0ad93 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift @@ -61,6 +61,35 @@ extension RunnerTests { } } + func testSynthesizedTextCommitProgressWalksExpectedPrefixOnly() { + let expected = "hardware-keyboard" + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: "hardware-keyboard", expectedText: expected), + .committed + ) + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: "", expectedText: expected), + .pending + ) + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: "hardware-keyboa", expectedText: expected), + .pending + ) + // Transformed input (formatter, mid-text caret, autocomplete) must stop the wait. + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: "hardwarX", expectedText: expected), + .diverged + ) + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: "hardware-keyboards", expectedText: expected), + .diverged + ) + XCTAssertEqual( + Self.synthesizedTextCommitProgress(observedText: nil, expectedText: expected), + .diverged + ) + } + #if os(iOS) func testSynthesizedTextEntryFallsBackOnlyWhenPrivateSynthesisIsUnavailable() { XCTAssertEqual( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index 06c47b17a..fde9ad91c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -111,8 +111,22 @@ extension RunnerTests { { textEntryRoute = "synthesized-first-responder" NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder") + let textBefore = editableTextValue(for: currentTarget, treatingPlaceholderAsEmpty: true) switch synthesizer.enterText(app: app, text: value, replacingExistingText: false) { case .continueTyping: + // No refresh point: like the tap-witness target itself, the commit wait must observe + // only the element the tap selected, never rediscover a different field. + awaitSynthesizedFirstResponderCommit( + app: app, + target: TextEntryTarget( + element: currentTarget, + refreshPoint: nil, + prefersFocusedElement: false, + fromTapWitness: true + ), + textBefore: textBefore, + typedText: value + ) return (currentTarget, true, nil) case .fallback: return (nil, false, .synthesisUnavailable)