diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 0fd974f719..4bc494a504 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -162,7 +162,8 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testPenalizedCoordinateTapOnNonTextControlDoesNotAuthorizeBareType \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand \ - -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedTextCommitProgressWalksExpectedPrefixOnly \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyUsesSynthesizedFirstResponderAfterHiddenKeyboardTap \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyRefusesWhenPrivateSynthesisIsUnavailable \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testEmptyReplacementWithoutResolvableTargetFailsClosed \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTapTextInputProbeSkipsPenalizedXCTestChannel \ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 6be914e7cc..2e87e03529 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -345,76 +345,6 @@ extension RunnerTests { #endif #if os(iOS) - func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws { - let command = try runnerCommandFixture( - #"{"command":"type","commandId":"type-without-focus","text":"hello"}"# - ) - - let response = executeTypeCommand( - activeApp: XCUIApplication(bundleIdentifier: "com.example.agentdevice.missing-input"), - command: command - ) - - XCTAssertFalse(response.ok) - XCTAssertEqual(response.error?.code, "TEXT_INPUT_NOT_FOCUSED") - XCTAssertEqual( - response.error?.hint, - "Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing." - ) - } - - func testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() throws { - // The fixture uses a real text responder with an empty input view to model hardware-keyboard input. - app.launchArguments = ["--agent-device-text-entry-regression"] - app.launch() - defer { - 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 frame = textField.frame - XCTAssertFalse(frame.isEmpty) - - let tapCommand = try runnerCommandFixture( - #"{"command":"tap","commandId":"tap-hardware-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)) - // A precondition, not a product claim. The fixture's empty `inputView` is what keeps the - // keyboard down, but nothing in this bundle owns the simulator's own keyboard settings, so an - // ambient flip that raised one here would be an environment fact — and reporting it as a - // failed assertion is what made this read as a product regression on unrelated PRs (#1874). - try XCTSkipIf( - isKeyboardVisible(app: app), - "software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path" - ) - - let failureCountBefore = currentXCTestFailureCount() - let typeCommand = try runnerCommandFixture( - #"{"command":"type","commandId":"type-hardware-keyboard","text":"hardware-keyboard"}"# - ) - let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand) - - XCTAssertTrue(typeResponse.ok, String(describing: typeResponse.error)) - XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) - XCTAssertEqual(typeResponse.data?.textEntryRoute, "synthesized-first-responder") - XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard") - - let secondFailureCountBefore = currentXCTestFailureCount() - let secondTypeCommand = try runnerCommandFixture( - #"{"command":"type","commandId":"type-hardware-keyboard-again","text":"-again"}"# - ) - let secondTypeResponse = executeTypeCommand(activeApp: app, command: secondTypeCommand) - - XCTAssertFalse(secondTypeResponse.ok) - XCTAssertEqual(secondTypeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED") - XCTAssertFalse(didRecordXCTestFailure(since: secondFailureCountBefore)) - XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard") - } - // `waitForTextEntryReadiness`'s hardware-keyboard fallback returns early only on confirmed // focus (#1874), and `keyboardFocusConfirmed` reads that from the app-wide focus predicate this // bundle otherwise refuses to trust. Two XCTest facts it rests on, neither a repository @@ -464,38 +394,6 @@ extension RunnerTests { "focus held by another element must read as a refusal, never as this element's focus" ) } - - func testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand() throws { - app.launchArguments = [ - "--agent-device-text-entry-regression", - "--agent-device-text-entry-disappear-after-input", - ] - app.launch() - defer { - 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-disappearing-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# - ) - let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app) - XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error)) - - let failureCountBefore = currentXCTestFailureCount() - let typeCommand = try runnerCommandFixture( - #"{"command":"type","commandId":"type-disappearing-input","text":"ab","delayMs":50}"# - ) - let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand) - - XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) - XCTAssertFalse(typeResponse.ok) - XCTAssertEqual(typeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED") - XCTAssertFalse(textField.exists) - } #endif func testInjectedTapRecordedFailureGateIsTapOnlyAndCountGated() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift index b88d2d5aa9..08edcf6cd8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift @@ -1,11 +1,11 @@ import XCTest -// The synthesized text-entry commit wait, end to end: the deadline that bounds it, the two waits -// that run it, the observation/pacing they poll through, and the value-free cadence line that path -// is allowed to log. Split from RunnerTests+SynthesizedTextEntry.swift, which keeps the -// private-XCTest synthesis boundary and the route policies; the pure outcome functions these wrap -// stay there next to the rules they encode. Everything that touches the polled field value now -// lives in this one file, which is the surface apple-runner-log-redaction.test.ts guards. +// The synthesized replacement commit wait, end to end: the deadline that bounds it, the wait that +// runs it, the observation/pacing it polls through, and the value-free cadence line that path is +// allowed to log. RunnerTests+SynthesizedTextEntry.swift keeps the private-XCTest synthesis +// boundary, the route policies, and the pure outcome function this wraps. Everything that touches +// the polled field value lives in this one file, which is the surface +// apple-runner-log-redaction.test.ts guards. extension RunnerTests { /// One commit wait's running deadline. /// @@ -57,54 +57,17 @@ extension RunnerTests { } } - /// 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 (#1676 rejected exactly that repair). Reporting `.notObserved` is - /// what the caller does instead: the partial value is the agent's to resolve, and a named - /// failure beats a success that misdescribes the field. 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 - ) -> SynthesizedTextCommitOutcome { - guard let textBefore, !typedText.contains("\n"), !typedText.contains("\r") else { - return .unobservable - } - let expectedText = textBefore + typedText - let waitStartedAt = Date() - NSLog("[DEBUG-1874] wait start expectedLen=%ld route=append", expectedText.count) - let ingredients = synthesizedCommitPollingIngredients(app: app, target: target, expectedText: expectedText) - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: expectedText, - placeholder: ingredients.placeholder, - now: { Date() }, - observe: ingredients.observe, - waitForNextObservation: ingredients.waitForNextObservation - ) - NSLog( - "[DEBUG-1874] wait outcome=%@ elapsedMs=%.0f route=append", - String(describing: outcome), - waitStartedAt.timeIntervalSinceNow * -1000 - ) - return outcome - } - /// Blocks until the synthesized replacement text (`fill`) is observable in the target field, so /// `fill` cannot report ok while the select-all-and-retype it posted is still uncommitted — or /// silently wrong — on a slow or channel-penalized simulator (this route runs only when the - /// XCTest channel is already penalized, and never resolves an `XCUIElement`, so it previously had - /// no verification at all). + /// XCTest channel is already penalized, and never resolves an `XCUIElement`). /// - /// Unlike the append route, the expected value is the final text itself — replacement mode - /// clears the field first, so there is no `textBefore` prefix to account for — and unlike the - /// append route, a settled mismatch is always reported rather than trusted: see - /// `awaitSynthesizedReplacementCommitOutcome`'s doc comment. + /// 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. 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. func awaitSynthesizedReplacementCommit( app: XCUIApplication, target: TextEntryTarget, @@ -160,16 +123,9 @@ extension RunnerTests { ) } - /// The placeholder/observe/pacing ingredients shared by the append route - /// (`awaitSynthesizedFirstResponderCommit`) and the replacement route - /// (`awaitSynthesizedReplacementCommit`). What must NOT be shared is which outcome function - /// consumes them: see `awaitSynthesizedReplacementCommitOutcome`'s doc comment for why append - /// mode's "trust a diverged value" rule is wrong for replacement mode. Each caller therefore - /// calls its own named outcome function directly, with real argument labels — deliberately not - /// a stored closure/function-value parameter here, which would erase those labels at the call - /// site and make the observe closure unrecognizable to the static content-redaction check in - /// `apple-runner-log-redaction.test.ts` (`extractObserveClosure` locates the labeled closure - /// literal by its text; a closure passed as a plain function value carries no such label). + /// The placeholder/observe/pacing ingredients the commit wait polls through. The observe closure + /// stays a labeled closure literal here: the static content-redaction check in + /// `apple-runner-log-redaction.test.ts` (`extractObserveClosure`) locates it by that text. private func synthesizedCommitPollingIngredients( app: XCUIApplication, target: TextEntryTarget, @@ -189,7 +145,7 @@ extension RunnerTests { treatingPlaceholderAsEmpty: true ) // Cadence evidence stays value-free: the polled value is user content typed through - // `type`/`fill` and must never reach runner.log. Lengths and the expected-prefix walk + // `fill` and must never reach runner.log. Lengths and the expected-prefix walk // are enough to distinguish throttling (prefix grows slowly) from a wedge (it freezes). Self.logCommitCadence( elapsedMs: Int(waitStartedAt.timeIntervalSinceNow * -1000), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 357af4f8d3..59086f3bd6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -127,10 +127,9 @@ extension RunnerTests { sleepFor(request.delaySeconds) } } - // The private synthesize call returns at post time, not commit time (same as bare `type`, - // see awaitSynthesizedFirstResponderCommit) — but this route never resolves an XCUIElement, - // so without this wait it had no way to notice a dropped or still-in-flight character at all - // and reported ok purely because the event posted. Wait here, on the same request.target + // The private synthesize call returns at post time, not commit time, and this route never + // resolves an XCUIElement, so without this wait it had no way to notice a dropped or + // still-in-flight character at all. Wait here, on the same request.target // (element nil, refreshPoint set) that gated this route, so each poll re-resolves via the // refresh point rather than trusting a stale element handle. let commit = awaitSynthesizedReplacementCommit( @@ -168,37 +167,16 @@ extension RunnerTests { !hasResolvedElement && hasRefreshPoint && xCTestChannelPenalized } + /// The tap-witness route carries only the bare submit key, the one text the daemon sends without + /// a text-entry mode. Ordinary `type` text arrives in `.append` mode and is typed through the + /// resolved XCUIElement, where it is verified. static func shouldUseSynthesizedFirstResponderType( repairMode: TextTypingRepairMode, + text: String, fromTapWitness: Bool, softwareKeyboardVisible: Bool ) -> Bool { - 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 + repairMode == .none && text == "\n" && fromTapWitness && !softwareKeyboardVisible } /// Length of the shared prefix of two strings. Feeds value-free commit-wait logging: the @@ -213,73 +191,19 @@ extension RunnerTests { return length } - /// How the commit wait ended. Distinct from `SynthesizedTextCommitProgress`, which classifies a - /// single observation: this is the whole wait's verdict, and it exists so the deadline can be - /// told apart from success. The wait used to return `Void`, which made an expired deadline - /// indistinguishable from a committed one — `type` then reported ok with a partial value in the - /// field (#1874, #1844). + /// How the commit wait ended: the whole wait's verdict, so the deadline can be told apart from + /// success. enum SynthesizedTextCommitOutcome: Equatable { - /// The wait's success case, but its meaning is route-specific: for append mode (bare `type`), - /// the expected text committed OR the app transformed the input in a way the runner must not - /// second-guess; for replacement mode (`fill`), it means only an exact match — see - /// `awaitSynthesizedReplacementCommitOutcome`'s doc comment for why replacement mode has no - /// "trust it" case. + /// The field holds exactly the expected text. case settled - /// There was nothing to wait for — no readable baseline, or the text carries a submit key. + /// There was nothing to wait for: the text carries a submit key. case unobservable /// The deadline expired with the expected text still not observed. case notObserved } - /// The commit wait's decision, with observation, pacing and the clock injected so both deadline - /// branches are exercisable without a simulator (the macOS host lane runs this; the member - /// wrapper below binds the real XCUI reads). - /// - /// The deadline is a local `var`, started from this loop's own first `now()` and advanced from - /// the same observation the progress check reads, so "did the burst move" and "is time up" are - /// two statements in one loop. The budget defaults to the shipped one, so only a test that is - /// asking about time has to name it. - static func awaitSynthesizedCommitOutcome( - expectedText: String, - placeholder: String?, - stallBudget: TimeInterval = TextEntryTiming.synthesizedCommitStallTimeout, - ceiling: TimeInterval = TextEntryTiming.synthesizedCommitCeiling, - now: () -> Date = { Date() }, - observe: () -> String?, - waitForNextObservation: () -> Void - ) -> SynthesizedTextCommitOutcome { - // A placeholder-equal AX value cannot prove a commit: an input handler may clear even a - // previously non-empty field after dispatch, making the empty field render the same value. - // Refuse before polling because no later read can distinguish those states. - if Self.textMatchesPlaceholder(expectedText, placeholder: placeholder) { - return .notObserved - } - var deadline = SynthesizedCommitDeadline(startedAt: now(), stallBudget: stallBudget, ceiling: ceiling) - // The deadline is checked AFTER an observation, never before one, so the last thing that - // happens before condemning a commit is a read. Checking first would condemn a commit that - // landed during the final poll sleep — the exact loaded-host timing this wait exists for. - while true { - let observedText = observe() - switch synthesizedTextCommitProgress(observedText: observedText, expectedText: expectedText) { - case .committed, .diverged: - return .settled - case .pending: - // One clock sample, so the instant an observation is recorded at is the instant it is - // judged against. - let sampledAt = now() - deadline.record( - expectedPrefixLength: Self.commonPrefixLength(observedText ?? "", expectedText), - at: sampledAt - ) - if deadline.isExpired(at: sampledAt) { return .notObserved } - waitForNextObservation() - } - } - } - - /// The command-level consequence of a commit wait. `.unobservable` is not a failure: there was - /// no baseline to compare against, which is the pre-existing contract for submit-key text and - /// unreadable fields, not evidence that anything went wrong. + /// The command-level consequence of a commit wait. `.unobservable` is not a failure: the app + /// may clear or rewrite the field on submit, so there is no value to compare against. static func textEntryFailure( forCommitOutcome outcome: SynthesizedTextCommitOutcome ) -> TextEntryFailure? { @@ -291,22 +215,16 @@ extension RunnerTests { } } - /// The replacement-mode counterpart of `awaitSynthesizedCommitOutcome`. It must NOT reuse that - /// function's `synthesizedTextCommitProgress`: prefix-walk's `.diverged` case exists to trust an - /// app that transforms bare-`type` input (formatter, autocomplete) rather than second-guess it — - /// but that same rule silently accepts a dropped-character corruption too, because a value with a - /// hole in the middle ("ada@example" -> "aexample") is neither a matching prefix NOR the full - /// string, yet still gets classified `.diverged` -> `.settled` -> reported `ok: true`. This is not - /// a hypothetical: it is the exact shape of the corruption this wait exists to catch (`fill` - /// reporting success over "Avelace"/"aexample"-style drops), verified against both live examples - /// before writing this comment. + /// The replacement commit wait's decision, with observation, pacing and the clock injected so + /// both deadline branches are exercisable without a simulator (the macOS host lane runs this; + /// `awaitSynthesizedReplacementCommit` binds the real XCUI reads). The budget defaults to the + /// shipped one, so only a test that is asking about time has to name it. /// - /// `.replacement` mode does not need prefix tolerance for legitimate transforms either: - /// `isRepairableTextEntryMismatch` (RunnerTests+TextTyping.swift) already treats every mismatch in - /// `.replacement` mode as repairable unconditionally, with no formatter/autocomplete carve-out — - /// `fill` fully owns the field via select-all, so there is no legitimate reason for the settled - /// value to be anything other than exactly what was requested. A settled non-match is therefore - /// always the wait's failure case, never a `.settled` pass-through. + /// Only an exact match settles. A value with a hole in the middle ("ada@example" -> "aexample") + /// is the corruption this wait exists to catch, and `.replacement` mode has no formatter or + /// autocomplete carve-out: `isRepairableTextEntryMismatch` (RunnerTests+TextTyping.swift) treats + /// every `.replacement` mismatch as repairable, because `fill` owns the whole field via + /// select-all. A settled non-match is therefore always the wait's failure case. static func awaitSynthesizedReplacementCommitOutcome( expectedText: String, placeholder: String?, @@ -316,18 +234,24 @@ extension RunnerTests { observe: () -> String?, waitForNextObservation: () -> Void ) -> SynthesizedTextCommitOutcome { + // 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. if Self.textMatchesPlaceholder(expectedText, placeholder: placeholder) { return .notObserved } var deadline = SynthesizedCommitDeadline(startedAt: now(), stallBudget: stallBudget, ceiling: ceiling) + // The deadline is checked AFTER an observation, never before one, so the last thing that + // happens before condemning a commit is a read. Checking first would condemn a commit that + // landed during the final poll sleep — the exact loaded-host timing this wait exists for. while true { let observedText = observe() if observedText == expectedText { return .settled } - // Prefix growth cannot settle this wait — a value with a hole in the middle is still a - // failure, see the doc comment above — but it is the same evidence that the burst is still - // landing, so it buys the same time here as it does in append mode. + // Prefix growth cannot settle this wait, but it is evidence that the burst is still + // landing, so it buys time. One clock sample, so the instant an observation is recorded at + // is the instant it is judged against. let sampledAt = now() deadline.record( expectedPrefixLength: Self.commonPrefixLength(observedText ?? "", expectedText), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index c3b3ec4b05..8aed63e9a5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -25,7 +25,7 @@ extension RunnerTests { case .notFocused: return "Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing." case .synthesisUnavailable: - return "Show the software keyboard, then retry type or fill." + 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." } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index 422da3e710..241d312dcb 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -77,6 +77,7 @@ extension RunnerTests { } let shouldUseSynthesizedFirstResponderType = Self.shouldUseSynthesizedFirstResponderType( repairMode: repairMode, + text: text, fromTapWitness: activeTarget.fromTapWitness, softwareKeyboardVisible: isKeyboardVisible(app: app) ) @@ -142,26 +143,9 @@ 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. - let commit = awaitSynthesizedFirstResponderCommit( - app: app, - target: TextEntryTarget( - element: currentTarget, - refreshPoint: nil, - prefersFocusedElement: false, - fromTapWitness: true - ), - textBefore: textBefore, - typedText: value - ) - // The characters were posted, so the element stays on the tuple for the caller's - // bookkeeping; the failure is what makes the command refuse. Reporting ok here is the - // defect this route had — the deadline was indistinguishable from a commit. - return (currentTarget, Self.textEntryFailure(forCommitOutcome: commit)) + return (currentTarget, nil) case .fallback: return (nil, .synthesisUnavailable) case .raise(let message): diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift index ed2b0001ca..e7bd7961c5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift @@ -42,7 +42,7 @@ extension RunnerTests { let failureCountBefore = currentXCTestFailureCount() let typeCommand = try runnerCommandFixture( - #"{"command":"type","commandId":"type-after-penalized-non-text-target","text":"must-not-type"}"# + #"{"command":"type","commandId":"type-after-penalized-non-text-target","text":"must-not-type","textEntryMode":"append"}"# ) let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedCommitDeadlineTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedCommitDeadlineTests.swift index 840a6b0ef4..f4be5b61e7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedCommitDeadlineTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedCommitDeadlineTests.swift @@ -2,7 +2,7 @@ import XCTest extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS - /// A hand-driven clock for the commit waits. Time moves only where the wait sleeps, which is + /// A hand-driven clock for the commit wait. Time moves only where the wait sleeps, which is /// what makes "the burst kept landing" and "the pipeline froze" expressible as two sequences of /// the same length rather than as wall-clock luck — and a test that never advances it cannot /// expire any budget, so only a test asking about time names `stallBudget`/`ceiling`. @@ -29,7 +29,7 @@ extension RunnerTests { let expected = "hardware" let clock = CommitWaitClock() var landed = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( + let outcome = Self.awaitSynthesizedReplacementCommitOutcome( expectedText: expected, placeholder: nil, stallBudget: 3, @@ -55,7 +55,7 @@ extension RunnerTests { let clock = CommitWaitClock() clock.advance(60) var polls = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( + let outcome = Self.awaitSynthesizedReplacementCommitOutcome( expectedText: "hardware", placeholder: nil, stallBudget: 3, @@ -76,7 +76,7 @@ extension RunnerTests { // fails today starts passing merely by waiting longer. func testCommitWaitCondemnsAFrozenPipelineAtTheStallBudget() { let clock = CommitWaitClock() - let outcome = Self.awaitSynthesizedCommitOutcome( + let outcome = Self.awaitSynthesizedReplacementCommitOutcome( expectedText: "hardware", placeholder: nil, stallBudget: 3, @@ -89,32 +89,9 @@ extension RunnerTests { XCTAssertEqual(clock.elapsed, 3, "a frozen prefix must give up on the stall budget, not the ceiling") } - // Progress buys time, but not without bound: one character per stall window would otherwise - // hold the command open until the daemon's own 45s budget killed the request. Here every poll - // lands a character, so only the ceiling can stop it. - func testCommitWaitCeilingStopsAnIndefinitelyThrottledPipeline() { - let clock = CommitWaitClock() - var landed = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: String(repeating: "a", count: 100), - placeholder: nil, - stallBudget: 3, - ceiling: 10, - now: clock.read, - observe: { String(repeating: "a", count: landed) }, - waitForNextObservation: { - landed += 1 - clock.advance(2) - } - ) - XCTAssertEqual(outcome, .notObserved) - XCTAssertEqual(clock.elapsed, 10, "the ceiling is absolute, however long characters keep arriving") - } - // Only forward movement is evidence the burst is still landing. A field the app clears // mid-flight would otherwise reset the stall clock on every poll and hold every wedged wait - // open to the ceiling. Replacement mode, because that is where a non-matching value keeps - // polling rather than settling as `.diverged`. + // open to the ceiling. func testCommitWaitTreatsARetreatingValueAsNoProgress() { let clock = CommitWaitClock() let observations = ["ada@", "", "ada@", "", "ada@"] @@ -135,11 +112,11 @@ extension RunnerTests { XCTAssertEqual(clock.elapsed, 3, "churn between two values is not progress and must not buy time") } - // The replacement route earns time the same way, and is bounded the same way — it is the route - // `fill` takes when the XCTest channel is penalized, i.e. the one that runs on a loaded host. - // Here every poll lands one more character of a value that never completes, so the wait can only - // end at the ceiling: it proves the growing prefix carried it past the 3s stall budget (a flat - // deadline stops at t=3) and that the ceiling still stops it. + // Progress buys time, but not without bound: one character per stall window would otherwise + // hold the command open until the daemon's own 45s budget killed the request. Here every poll + // lands one more character of a value that never completes, so the wait can only end at the + // ceiling: it proves the growing prefix carried it past the 3s stall budget (a flat deadline + // stops at t=3) and that the ceiling still stops it. func testReplacementCommitWaitOutlivesTheFlatDeadlineThenStopsAtTheCeiling() { let expected = "ada@example.com" let clock = CommitWaitClock() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift index beff83964c..556dd1aa65 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift @@ -41,155 +41,34 @@ extension RunnerTests { } } - func testSynthesizedFirstResponderTypeRequiresHiddenKeyboardTapWitness() { - let cases: [(TextTypingRepairMode, Bool, Bool, Bool)] = [ - (.none, true, false, true), - (.none, true, true, false), - (.none, false, false, false), - (.append, true, false, false), - (.replacement, true, false, false), + func testSynthesizedFirstResponderTypeAdmitsOnlyTheBareSubmitKeyAfterAHiddenKeyboardTap() { + let cases: [(TextTypingRepairMode, String, Bool, Bool, Bool)] = [ + (.none, "\n", true, false, true), + (.none, "\n", true, true, false), + (.none, "\n", false, false, false), + (.none, "hardware-keyboard", true, false, false), + (.none, "\r", true, false, false), + (.none, "search\n", true, false, false), + (.append, "\n", true, false, false), + (.append, "hardware-keyboard", true, false, false), + (.replacement, "\n", true, false, false), ] - for (mode, fromTapWitness, softwareKeyboardVisible, expected) in cases { + for (mode, text, fromTapWitness, softwareKeyboardVisible, expected) in cases { XCTAssertEqual( Self.shouldUseSynthesizedFirstResponderType( repairMode: mode, + text: text, fromTapWitness: fromTapWitness, softwareKeyboardVisible: softwareKeyboardVisible ), - expected + expected, + "mode: \(mode), text: \(text.debugDescription)" ) } } - 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 - ) - } - - // The regression behind #1874/#1844: the wait used to return Void, so an expired deadline was - // indistinguishable from a commit and `type` reported ok over a partially committed field. The - // CI signature was a field holding "h" out of "hardware-keyboard" with the command successful. - func testSynthesizedCommitDeadlineIsNotReportedAsACommit() { - let clock = CommitWaitClock() - var observations = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: "hardware-keyboard", - placeholder: nil, - stallBudget: 3, - ceiling: 10, - now: clock.read, - observe: { "h" }, - waitForNextObservation: { - observations += 1 - clock.advance(1) - } - ) - XCTAssertEqual(outcome, .notObserved) - XCTAssertEqual(observations, 3, "a pending prefix must keep polling until the deadline") - } - - func testSynthesizedCommitStopsAtTheFirstSettledObservation() { - for observed in ["hardware-keyboard", "hardwarX", nil] { - var polls = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: "hardware-keyboard", - placeholder: nil, - observe: { observed }, - waitForNextObservation: { polls += 1 } - ) - // `.diverged` settles the wait too: the app transformed the input and the runner must not - // second-guess it. Only an outstanding strict prefix keeps waiting. - XCTAssertEqual(outcome, .settled, "observed: \(observed ?? "nil")") - XCTAssertEqual(polls, 0, "observed: \(observed ?? "nil")") - } - } - - func testSynthesizedCommitWalksAPrefixToCompletion() { - let steps = ["", "hardware-", "hardware-keyboard"] - var index = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: "hardware-keyboard", - placeholder: nil, - observe: { steps[min(index, steps.count - 1)] }, - waitForNextObservation: { index += 1 } - ) - XCTAssertEqual(outcome, .settled) - XCTAssertEqual(index, 2) - } - - // Adversarial-review finding: the deadline used to be checked BEFORE observing, so a commit - // landing during the final poll sleep was condemned as never observed — a false failure under - // exactly the loaded-host timing this wait exists for. Red against that ordering. - func testCommitLandingDuringTheFinalSleepIsStillObserved() { - let clock = CommitWaitClock() - var polls = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: "hardware-keyboard", - placeholder: nil, - stallBudget: 3, - ceiling: 10, - now: clock.read, - // The value lands during the sleep that takes the clock past the stall budget: the read - // happens first, so it is still observed. - observe: { polls == 0 ? "hardware-" : "hardware-keyboard" }, - waitForNextObservation: { - polls += 1 - clock.advance(9) - } - ) - XCTAssertEqual(outcome, .settled) - } - - // A pre-dispatch value cannot identify what a later placeholder-equal AX value represents. - // Here the field starts at "0", but its input handler clears it after `type ".00"`; the empty - // field then renders its "0.00" placeholder. Reporting success would describe an empty field as - // committed text. - func testClearAfterDispatchCannotTurnThePlaceholderIntoCommitEvidence() { - let textBeforeDispatch = "0" - let expectedText = textBeforeDispatch + ".00" - var observations = 0 - let outcome = Self.awaitSynthesizedCommitOutcome( - expectedText: expectedText, - placeholder: "0.00", - observe: { - observations += 1 - return "0.00" - }, - waitForNextObservation: {} - ) - XCTAssertEqual( - Self.textEntryFailure(forCommitOutcome: outcome)?.rawValue, - "TEXT_INPUT_COMMIT_NOT_OBSERVED" - ) - XCTAssertEqual(observations, 0, "no post-dispatch read can resolve this collision") - } - // The guard must stay narrow: it fires only when the WHOLE expected value is the placeholder. - // Widening it would refuse ordinary typing into any placeheld field, which is most of them. + // Widening it would refuse ordinary entry into any placeheld field, which is most of them. func testPlaceholderGuardDoesNotFireOnOrdinaryTyping() { let cases: [(placeholder: String?, expectedText: String)] = [ ("0.00", "0.005"), @@ -199,7 +78,7 @@ extension RunnerTests { (" ", ""), ] for testCase in cases { - let outcome = Self.awaitSynthesizedCommitOutcome( + let outcome = Self.awaitSynthesizedReplacementCommitOutcome( expectedText: testCase.expectedText, placeholder: testCase.placeholder, observe: { testCase.expectedText }, @@ -209,32 +88,16 @@ extension RunnerTests { } } - // The bug this whole route exists to fix: `awaitSynthesizedCommitOutcome` (append/`type`) treats - // any non-prefix value as `.diverged` -> `.settled`, i.e. "trust the app, don't second-guess it." - // That rule is correct for `type` (an autocomplete/formatter can legitimately transform bare - // input) but silently swallows a dropped-character corruption in `.replacement` mode, because a - // value with a hole in the middle is neither a matching prefix nor an exact match — it still hits - // `.diverged`. These are the two corruption strings actually observed in CI on `fill` + // 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). Confirms - // `awaitSynthesizedReplacementCommitOutcome` reports `.notObserved` for both, where - // `awaitSynthesizedCommitOutcome` (proven by the assertion inside the loop) reports `.settled`. + // first character and tail survive, a middle run is missing). func testSynthesizedReplacementCommitCatchesDroppedMiddleCharacters() { let corruptions: [(expected: String, observedAfterDrop: String)] = [ (expected: "Ada Lovelace", observedAfterDrop: "Avelace"), (expected: "ada@example", observedAfterDrop: "aexample"), ] for corruption in corruptions { - XCTAssertEqual( - Self.awaitSynthesizedCommitOutcome( - expectedText: corruption.expected, - placeholder: nil, - observe: { corruption.observedAfterDrop }, - waitForNextObservation: {} - ), - .settled, - "append-mode's diverge-trusting outcome must stay unchanged by this fix" - ) let clock = CommitWaitClock() var polls = 0 let outcome = Self.awaitSynthesizedReplacementCommitOutcome( @@ -255,10 +118,7 @@ extension RunnerTests { } // The non-failure counterpart: replacement mode must still tolerate real commit lag (the value - // converges to an exact match over a few polls), not just instant matches. Mirrors - // `testSynthesizedCommitWalksAPrefixToCompletion`, but replacement mode has no "prefix" concept — - // every intermediate read here is deliberately NOT a prefix of the final value, to prove the wait - // does not depend on prefix-walking to keep polling. + // converges to an exact match over a few polls), not just instant matches. func testSynthesizedReplacementCommitToleratesLagUntilExactMatch() { let steps = ["", "ad", "ada@example"] var index = 0 @@ -272,8 +132,8 @@ extension RunnerTests { XCTAssertEqual(index, 2) } - // Same ordering guarantee as `testCommitLandingDuringTheFinalSleepIsStillObserved`: the deadline - // is checked AFTER an observation, so a match landing during the final poll sleep is still caught. + // The deadline is checked AFTER an observation, so a match landing during the final poll sleep + // is still caught. func testSynthesizedReplacementCommitLandingDuringTheFinalSleepIsStillObserved() { let clock = CommitWaitClock() var polls = 0 @@ -292,8 +152,9 @@ extension RunnerTests { XCTAssertEqual(outcome, .settled) } - // Same placeholder-collision guard as append mode, and for the same reason: a pre-dispatch value - // cannot identify what a later placeholder-equal AX value represents, so refuse before polling. + // A pre-dispatch value cannot identify what a later placeholder-equal AX value represents: an + // input handler may clear the field after dispatch and the empty field then renders the + // placeholder. Reporting success would describe an empty field as committed text. func testSynthesizedReplacementCommitPlaceholderGuardRefusesWithoutPolling() { var observations = 0 let outcome = Self.awaitSynthesizedReplacementCommitOutcome( @@ -310,8 +171,7 @@ extension RunnerTests { } // The mapping the command actually refuses on. `.unobservable` must stay a success: it is the - // pre-existing contract for submit-key text and unreadable fields, so inverting it would fail - // every `type "...\n"`. + // contract for submit-key text, so inverting it would fail every `fill` ending in a submit key. func testOnlyAnUnobservedCommitBecomesACommandFailure() { XCTAssertNil(Self.textEntryFailure(forCommitOutcome: .settled)) XCTAssertNil(Self.textEntryFailure(forCommitOutcome: .unobservable)) @@ -465,9 +325,8 @@ extension RunnerTests { XCTAssertNil(result.observedText) } - // Companion to the above: text carrying a submit key must skip the wait entirely, same as the - // append route (`awaitSynthesizedFirstResponderCommit`) — the app may clear or rewrite the field - // on submit, so there is nothing meaningful to poll toward. + // Companion to the above: text carrying a submit key must skip the wait entirely — the app may + // clear or rewrite the field on submit, so there is nothing meaningful to poll toward. func testSynthesizedReplacementCommitSkipsSubmitKeyText() { for expectedText in ["ada@example.test\n", "ada@example.test\r"] { XCTAssertEqual( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift index fd1a0d51c9..28686cec65 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift @@ -75,10 +75,11 @@ extension RunnerTests { XCTAssertNotNil(textEntryTapWitness) XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) try XCTSkipIf(isKeyboardVisible(app: app), "software keyboard is up; hidden-keyboard witness cannot be exercised") - let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-healthy-probe","text":"probe-witness"}"#) + let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-healthy-probe","text":"probe-witness","textEntryMode":"append"}"#) let typed = try execute(command: type) XCTAssertTrue(typed.ok, String(describing: typed.error)) - XCTAssertEqual(typed.data?.textEntryRoute, "synthesized-first-responder") + XCTAssertEqual(typed.data?.textEntryRoute, "xctest-element") + XCTAssertNil(textEntryTapWitness, "the type must consume the tap witness it was addressed by") XCTAssertEqual(field.value as? String, "probe-witness") XCTAssertFalse(didRecordXCTestFailure(since: failures)) } @@ -153,7 +154,7 @@ extension RunnerTests { XCTAssertFalse(didRecordXCTestFailure(since: failures)) XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) XCTAssertNil(textEntryTapWitness) - let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type"}"#) + let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type","textEntryMode":"append"}"#) let typed = try execute(command: type) XCTAssertFalse(typed.ok) XCTAssertEqual(typed.error?.code, "TEXT_INPUT_NOT_FOCUSED") diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift new file mode 100644 index 0000000000..214f599bd6 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift @@ -0,0 +1,193 @@ +import XCTest + +// Command-level `type` coverage in the request shapes the daemon sends: ordinary text in +// `textEntryMode: "append"`, and the bare submit key with no mode. +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws { + let command = try runnerCommandFixture( + #"{"command":"type","commandId":"type-without-focus","text":"hello","textEntryMode":"append"}"# + ) + + let response = executeTypeCommand( + activeApp: XCUIApplication(bundleIdentifier: "com.example.agentdevice.missing-input"), + command: command + ) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "TEXT_INPUT_NOT_FOCUSED") + XCTAssertEqual( + response.error?.hint, + "Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing." + ) + } + + func testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() throws { + // The fixture uses a real text responder with an empty input view to model hardware-keyboard input. + let textField = try launchHardwareKeyboardFixture() + try tapHardwareKeyboardInput(commandId: "tap-hardware-keyboard-input") + try skipUnlessSoftwareKeyboardIsHidden() + + let failureCountBefore = currentXCTestFailureCount() + let typeResponse = executeTypeCommand( + activeApp: app, + command: try runnerCommandFixture( + #"{"command":"type","commandId":"type-hardware-keyboard","text":"hardware-keyboard","textEntryMode":"append"}"# + ) + ) + + XCTAssertTrue(typeResponse.ok, String(describing: typeResponse.error)) + XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) + XCTAssertEqual(typeResponse.data?.textEntryRoute, "xctest-element") + XCTAssertNil(textEntryTapWitness, "the type must consume the tap witness it was addressed by") + XCTAssertEqual(textField.value as? String, "hardware-keyboard") + + // The tap witness is one-shot: a second bare type without a new tap has no target. + let unfocusedFailureCountBefore = currentXCTestFailureCount() + let unfocusedResponse = executeTypeCommand( + activeApp: app, + command: try runnerCommandFixture( + #"{"command":"type","commandId":"type-hardware-keyboard-unfocused","text":"-again","textEntryMode":"append"}"# + ) + ) + + XCTAssertFalse(unfocusedResponse.ok) + XCTAssertEqual(unfocusedResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED") + XCTAssertFalse(didRecordXCTestFailure(since: unfocusedFailureCountBefore)) + XCTAssertEqual(textField.value as? String, "hardware-keyboard") + + try tapHardwareKeyboardInput(commandId: "tap-hardware-keyboard-input-again") + // The first tap already proved this simulator keeps the keyboard down for the fixture, so a + // keyboard here is a product change, not an environment fact. + XCTAssertFalse(isKeyboardVisible(app: app)) + let appendFailureCountBefore = currentXCTestFailureCount() + let appendResponse = executeTypeCommand( + activeApp: app, + command: try runnerCommandFixture( + #"{"command":"type","commandId":"type-hardware-keyboard-again","text":"-again","textEntryMode":"append"}"# + ) + ) + + XCTAssertTrue(appendResponse.ok, String(describing: appendResponse.error)) + XCTAssertFalse(didRecordXCTestFailure(since: appendFailureCountBefore)) + XCTAssertEqual(appendResponse.data?.textEntryRoute, "xctest-element") + XCTAssertEqual(textField.value as? String, "hardware-keyboard-again") + } + + func testBareSubmitKeyUsesSynthesizedFirstResponderAfterHiddenKeyboardTap() throws { + _ = try launchHardwareKeyboardFixture() + try tapHardwareKeyboardInput(commandId: "tap-hardware-keyboard-submit") + try skipUnlessSoftwareKeyboardIsHidden() + + let failureCountBefore = currentXCTestFailureCount() + let submitResponse = executeTypeCommand( + activeApp: app, + command: try runnerCommandFixture( + #"{"command":"type","commandId":"type-hardware-keyboard-submit","text":"\n"}"# + ) + ) + + XCTAssertTrue(submitResponse.ok, String(describing: submitResponse.error)) + XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) + XCTAssertEqual(submitResponse.data?.textEntryRoute, "synthesized-first-responder") + XCTAssertNil(textEntryTapWitness, "the submit must consume the tap witness it was addressed by") + } + + func testBareSubmitKeyRefusesWhenPrivateSynthesisIsUnavailable() throws { + let textField = try launchHardwareKeyboardFixture() + try skipUnlessSoftwareKeyboardIsHidden() + + let failureCountBefore = currentXCTestFailureCount() + let result = typeTextReliably( + app: app, + target: TextEntryTarget( + element: textField, + refreshPoint: nil, + prefersFocusedElement: false, + fromTapWitness: true + ), + text: "\n", + delaySeconds: 0, + synthesizer: UnavailableTextEntrySynthesizer() + ) + + XCTAssertEqual(result.failure, .synthesisUnavailable) + XCTAssertEqual(result.textEntryRoute, "synthesized-first-responder") + XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) + } + + func testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand() throws { + app.launchArguments = [ + "--agent-device-text-entry-regression", + "--agent-device-text-entry-disappear-after-input", + ] + app.launch() + defer { + 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-disappearing-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# + ) + let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app) + XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error)) + + let failureCountBefore = currentXCTestFailureCount() + let typeCommand = try runnerCommandFixture( + #"{"command":"type","commandId":"type-disappearing-input","text":"ab","delayMs":50,"textEntryMode":"append"}"# + ) + let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand) + + XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) + XCTAssertFalse(typeResponse.ok) + XCTAssertEqual(typeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED") + XCTAssertFalse(textField.exists) + } + + private struct UnavailableTextEntrySynthesizer: TextEntrySynthesizing { + func enterText( + app _: XCUIApplication, + text _: String, + replacingExistingText _: Bool + ) -> SynthesizedTextEntryAction { + .fallback + } + } + + private func launchHardwareKeyboardFixture() throws -> XCUIElement { + app.launchArguments = ["--agent-device-text-entry-regression"] + 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)) + XCTAssertFalse(textField.frame.isEmpty) + return textField + } + + private func tapHardwareKeyboardInput(commandId: String) throws { + let tapCommand = try runnerCommandFixture( + #"{"command":"tap","commandId":"\#(commandId)","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# + ) + let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app) + XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error)) + } + + // A precondition, not a product claim. The fixture's empty `inputView` is what keeps the + // keyboard down, but nothing in this bundle owns the simulator's own keyboard settings, so an + // ambient keyboard here is an environment fact rather than a product regression. + private func skipUnlessSoftwareKeyboardIsHidden() throws { + try XCTSkipIf( + isKeyboardVisible(app: app), + "software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path" + ) + } +#endif +} diff --git a/src/__tests__/apple-runner-log-redaction.test.ts b/src/__tests__/apple-runner-log-redaction.test.ts index 3cb18245bf..25b266a502 100644 --- a/src/__tests__/apple-runner-log-redaction.test.ts +++ b/src/__tests__/apple-runner-log-redaction.test.ts @@ -10,8 +10,8 @@ const commitWaitPath = path.join( 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift', ); -// The synthesized bare-type commit wait polls the target field's live value on the shipped -// `type` path. That value is user content — a `type` command may carry credentials, tokens, or +// The synthesized replacement commit wait polls the target field's live value on the shipped +// `fill` path. That value is user content — a `fill` command may carry credentials, tokens, or // PII — and runner.log persists across the session. Cadence evidence must stay value-free. // // The enforcement is a typed Swift boundary (`logCommitCadence`), whose parameters are Ints diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 5a675ccdc6..cb6ba7c8f8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -485,8 +485,8 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and `fill` clears then types. `type` does not clear. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. 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` reports `TEXT_INPUT_SYNTHESIS_UNAVAILABLE` while the software keyboard is hidden, show the software keyboard, then retry `type` or `fill`. The runner reports this error instead of risking partial input through an unreliable text-entry path. -On iOS, if `type` or `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 bare-type route and the coordinate-driven `fill` route taken when the accessibility channel is under load, both of which observe the field after synthesizing; it is not a guarantee that every text-entry route verifies its result. +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. 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.