diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 0759692075..ca87a188f9 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -884,7 +884,7 @@ extension RunnerTests { ok: false, error: ErrorPayload( code: "UNSUPPORTED_OPERATION", - message: "Unable to dismiss the iOS keyboard without a native dismiss gesture or control" + message: "Unable to dismiss the iOS keyboard without a safe native dismiss control" ) ) } diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index 38460b54b8..55d074bc76 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -355,27 +355,12 @@ extension RunnerTests { let visible = isKeyboardVisible(app: app) return (wasVisible: true, dismissed: !visible, visible: visible) #else - let keyboard = app.keyboards.firstMatch - keyboard.swipeDown() - sleepFor(0.2) - if !isKeyboardVisible(app: app) { - return (wasVisible: true, dismissed: true, visible: false) - } - if tapKeyboardDismissControl(app: app) { sleepFor(0.2) let visible = isKeyboardVisible(app: app) return (wasVisible: true, dismissed: !visible, visible: visible) } - if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) { - sleepFor(0.2) - let visible = isKeyboardVisible(app: app) - if !visible { - return (wasVisible: true, dismissed: true, visible: false) - } - } - return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app)) #endif } @@ -478,7 +463,7 @@ extension RunnerTests { label, label ) - let toolbarButtons = app.toolbars.buttons + let toolbarButtons = app.descendants(matching: .button) .matching(toolbarButtonPredicate) .allElementsBoundByIndex if let hittable = toolbarButtons.first(where: { @@ -492,10 +477,7 @@ extension RunnerTests { #endif } - private func tapKeyboardReturnControl( - app: XCUIApplication, - allowCoordinateFallback: Bool = false - ) -> Bool { + private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool { #if os(iOS) for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] { let candidates = [ @@ -506,21 +488,6 @@ extension RunnerTests { hittable.tap() return true } - if allowCoordinateFallback, - let keyboardFrame = visibleKeyboardFrame(app: app), - let framed = candidates.first(where: { - guard $0.exists else { return false } - let frame = $0.frame - return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY)) - }) { - let frame = framed.frame - switch tapAt(app: app, x: frame.midX, y: frame.midY) { - case .performed: - return true - case .unsupported: - return false - } - } } #endif return false diff --git a/ios-runner/RUNNER_PROTOCOL.md b/ios-runner/RUNNER_PROTOCOL.md index 8950c990e9..8afd798a64 100644 --- a/ios-runner/RUNNER_PROTOCOL.md +++ b/ios-runner/RUNNER_PROTOCOL.md @@ -62,7 +62,7 @@ Successful and failed responses use the same top-level envelope: "ok": false, "error": { "code": "UNSUPPORTED_OPERATION", - "message": "Unable to dismiss the iOS keyboard without a native dismiss gesture or control" + "message": "Unable to dismiss the iOS keyboard without a safe native dismiss control" } } ``` diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index b1e18ee945..52b49882e0 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -194,7 +194,7 @@ Text entry: agent-device type "Handle with care" --delay-ms 80 Empty replacement is not a supported clear-field command: do not plan fill "" or fill ''. Prefer a visible clear/reset control; if the app exposes none, report the tool gap instead of inventing a clear command. Debounced field with no result selector: agent-device wait 1000. Keyboard read-only: keyboard status/get. Blocked control: try keyboard dismiss when supported. - On iOS, prefer keyboard dismiss before manually pressing visible Done; the runner can use safe native keyboard controls and still reports unsupported layouts explicitly. If it returns UNSUPPORTED_OPERATION, prefer a visible app dismiss control, or use back --system only when system navigation is an acceptable side effect. + To hide the keyboard, use keyboard dismiss. It taps safe controls like Done when available and verifies the keyboard closed. If it reports UNSUPPORTED_OPERATION, press a visible app control such as Done only when that is the intended fallback. Use plain fill/type first for ordinary login and form fields. If an iOS debounced or search-as-you-type field actually drops characters, or must receive incremental updates, retry with --delay-ms before trying clipboard paste; --delay-ms intentionally paces character entry. iOS Allow Paste prompt cannot be exercised under XCUITest. To test paste-driven app behavior, prefill first with agent-device clipboard write "some text"; test the system prompt manually. Android Gboard handwriting/stylus UI can capture text in an IME-owned input instead of the app field. If fill reports that input was captured by the keyboard/IME, use the diagnostic targetInput/actualInput details, inspect keyboard status/get if needed, and switch or disable handwriting outside the command plan before retrying. Do not keep retrying fill/type against the same field while the IME owns focus. diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index f2bc0c0488..b16d411e53 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -134,7 +134,7 @@ const rotateCliSchema = { const keyboardCliSchema = { usageOverride: 'keyboard [status|get|dismiss|enter|return]', helpDescription: - 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. For iOS toolbar Done dismissal, focus the field by id/ref first, then run keyboard dismiss; do not manually press Done unless keyboard dismiss reports unsupported.', + 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. To hide the keyboard, use keyboard dismiss. It taps safe controls like Done when available, verifies the keyboard closed, and reports UNSUPPORTED_OPERATION when no safe control is available.', summary: 'Inspect, press, or dismiss the device keyboard', positionalArgs: ['action?'], } as const satisfies CommandSchemaOverride; diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index b5cc5b76b8..3545402c93 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -619,7 +619,7 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async ( ok: false, error: { code: 'UNSUPPORTED_OPERATION', - message: 'Unable to dismiss the iOS keyboard without a native dismiss gesture or control', + message: 'Unable to dismiss the iOS keyboard without a safe native dismiss control', }, }), ); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index ce089d0f01..f3f4af0b4a 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1110,8 +1110,9 @@ test('usageForCommand supports legacy long-press alias', () => { test('usageForCommand documents keyboard dismissal flow', () => { const help = usageForCommand('keyboard'); assert.equal(help === null, false); - assert.match(help ?? '', /focus the field by id\/ref first/); - assert.match(help ?? '', /keyboard dismiss reports unsupported/); + assert.match(help ?? '', /To hide the keyboard, use keyboard dismiss/); + assert.match(help ?? '', /taps safe controls like Done/); + assert.match(help ?? '', /UNSUPPORTED_OPERATION/); }); test('usageForCommand supports metrics alias', () => { @@ -1434,7 +1435,11 @@ test('usageForCommand resolves workflow help topic', () => { assert.match(help, /iOS Allow Paste prompt cannot be exercised under XCUITest/); assert.match(help, /Empty replacement is not a supported clear-field command/); assert.match(help, /do not plan fill ""/); - assert.match(help, /prefer keyboard dismiss before manually pressing visible Done/); + assert.match(help, /To hide the keyboard, use keyboard dismiss/); + assert.match( + help, + /press a visible app control such as Done only when that is the intended fallback/, + ); assert.match(help, /UNSUPPORTED_OPERATION/); assert.match(help, /Stateful commands within one session must run serially/); assert.match( diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index efe558e94e..87560a642d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -610,9 +610,8 @@ agent-device keyboard dismiss ``` - `keyboard status` (or `keyboard get`) returns keyboard visibility and best-effort input type classification on Android. -- `keyboard dismiss` attempts a non-navigation keyboard dismissal on Android and a native dismiss gesture/control on iOS, including common safe controls such as a keyboard toolbar `Done` button, then confirms the keyboard is hidden. -- If the keyboard remains visible after the platform-native dismiss path, the command returns an explicit `UNSUPPORTED_OPERATION` error instead of falling back to back navigation. -- On iOS, `keyboard dismiss` is best-effort and can fail when the active app exposes no native dismiss gesture/control. Prefer a visible app dismiss control, or use `back --system` only when system navigation is an acceptable side effect. +- To hide the keyboard, use `keyboard dismiss`. It taps safe controls like `Done` when available and verifies the keyboard closed. +- If it reports `UNSUPPORTED_OPERATION`, press a visible app control such as `Done` only when that is the intended fallback. - Works with active sessions and explicit selectors (`--platform`, `--device`, `--udid`, `--serial`). - `keyboard status|get` is supported on Android emulator/device. - `keyboard dismiss` is supported on Android emulator/device and best-effort on iOS simulator/device.