From 0cf9d41fc968b804e363b0caf1176ae4b8af6785 Mon Sep 17 00:00:00 2001 From: Nguyen Phuc Loi Date: Fri, 15 Jul 2022 07:21:11 +0700 Subject: [PATCH] [flutter_driver] support send text input action (#106561) * Support receive input action * Fix error syntax * Fix compile * Add documents * Add unit-test * Update import * Fixed unit-test and lint * Add authors for me * Fixed lint * Fixed lint * Add example * Fixed lint * Fix gen docs * Revert code * Remove flutter_dev * Update packages/flutter_driver/lib/src/driver/driver.dart Co-authored-by: Tong Mu * Update packages/flutter_driver/lib/src/common/action.dart Co-authored-by: Tong Mu * Update packages/flutter_driver/lib/src/common/action.dart Co-authored-by: Tong Mu * Rename ReceiveAction to SendTextInputAction * Rename DriverTextInputAction to TextInputAction and fix unit-test * Reorder import * Remove space * Reorder import * Update text_input.dart * Update flutter_driver_test.dart * Update comment to normal comment after dart doc * Update example * Update AUTHORS Co-authored-by: Tong Mu * Fix analyze * Add type dart for example * Add unit-test to check the same entries Co-authored-by: Tong Mu --- AUTHORS | 1 + .../flutter/lib/src/services/text_input.dart | 3 + .../flutter_driver/lib/flutter_driver.dart | 1 + .../lib/src/common/handler_factory.dart | 12 ++ .../lib/src/common/text_input_action.dart | 189 ++++++++++++++++++ .../flutter_driver/lib/src/driver/driver.dart | 29 +++ .../src/real_tests/flutter_driver_test.dart | 11 + .../real_tests/text_input_action_test.dart | 19 ++ 8 files changed, 265 insertions(+) create mode 100644 packages/flutter_driver/lib/src/common/text_input_action.dart create mode 100644 packages/flutter_driver/test/src/real_tests/text_input_action_test.dart diff --git a/AUTHORS b/AUTHORS index a3fcb825ea..a1f9ed9f33 100644 --- a/AUTHORS +++ b/AUTHORS @@ -94,3 +94,4 @@ Twin Sun, LLC Taskulu LDA Jonathan Joelson Elsabe Ros +Nguyễn Phúc Lợi diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 4fee13bb9b..894b6df551 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -258,6 +258,9 @@ class TextInputType { /// /// * [TextInput], which configures the platform's keyboard setup. /// * [EditableText], which invokes callbacks when the action button is pressed. +// +// This class has been cloned to `flutter_driver/lib/src/common/action.dart` as `TextInputAction`, +// and must be kept in sync. enum TextInputAction { /// Logical meaning: There is no relevant input action for the current input /// source, e.g., [TextField]. diff --git a/packages/flutter_driver/lib/flutter_driver.dart b/packages/flutter_driver/lib/flutter_driver.dart index 232bbecedd..dc6e1288a4 100644 --- a/packages/flutter_driver/lib/flutter_driver.dart +++ b/packages/flutter_driver/lib/flutter_driver.dart @@ -26,6 +26,7 @@ export 'src/common/render_tree.dart'; export 'src/common/request_data.dart'; export 'src/common/semantics.dart'; export 'src/common/text.dart'; +export 'src/common/text_input_action.dart'; export 'src/common/wait.dart'; export 'src/driver/common.dart'; export 'src/driver/driver.dart'; diff --git a/packages/flutter_driver/lib/src/common/handler_factory.dart b/packages/flutter_driver/lib/src/common/handler_factory.dart index 6254326572..6289da676e 100644 --- a/packages/flutter_driver/lib/src/common/handler_factory.dart +++ b/packages/flutter_driver/lib/src/common/handler_factory.dart @@ -26,6 +26,7 @@ import 'render_tree.dart'; import 'request_data.dart'; import 'semantics.dart'; import 'text.dart'; +import 'text_input_action.dart' show SendTextInputAction; import 'wait.dart'; /// A factory which creates [Finder]s from [SerializableFinder]s. @@ -159,6 +160,7 @@ mixin CommandHandlerFactory { case 'get_layer_tree': return _getLayerTree(command); case 'get_render_tree': return _getRenderTree(command); case 'enter_text': return _enterText(command); + case 'send_text_input_action': return _sendTextInputAction(command); case 'get_text': return _getText(command, finderFactory); case 'request_data': return _requestData(command); case 'scroll': return _scroll(command, prober, finderFactory); @@ -204,6 +206,16 @@ mixin CommandHandlerFactory { return Result.empty; } + Future _sendTextInputAction(Command command) async { + if (!_testTextInput.isRegistered) { + throw StateError('Unable to fulfill `FlutterDriver.sendTextInputAction`. Text emulation is ' + 'disabled. You can enable it using `FlutterDriver.setTextEntryEmulation`.'); + } + final SendTextInputAction sendTextInputAction = command as SendTextInputAction; + _testTextInput.receiveAction(TextInputAction.values[sendTextInputAction.textInputAction.index]); + return Result.empty; + } + Future _requestData(Command command) async { final RequestData requestDataCommand = command as RequestData; final DataHandler? dataHandler = getDataHandler(); diff --git a/packages/flutter_driver/lib/src/common/text_input_action.dart b/packages/flutter_driver/lib/src/common/text_input_action.dart new file mode 100644 index 0000000000..6a64b195d2 --- /dev/null +++ b/packages/flutter_driver/lib/src/common/text_input_action.dart @@ -0,0 +1,189 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'enum_util.dart'; +import 'message.dart'; + +EnumIndex _textInputActionIndex = + EnumIndex(TextInputAction.values); + +/// A Flutter Driver command that send a text input action. +class SendTextInputAction extends Command { + /// Creates a command that enters text into the currently focused widget. + const SendTextInputAction(this.textInputAction, {super.timeout}); + + /// Deserializes this command from the value generated by [serialize]. + SendTextInputAction.deserialize(super.json) + : textInputAction = + _textInputActionIndex.lookupBySimpleName(json['action']!), + super.deserialize(); + + /// The [TextInputAction] + final TextInputAction textInputAction; + + @override + String get kind => 'send_text_input_action'; + + @override + Map serialize() => super.serialize() + ..addAll({ + 'action': _textInputActionIndex.toSimpleName(textInputAction), + }); +} + +/// An action the user has requested the text input control to perform. +/// +// This class is identical to [TextInputAction](https://api.flutter.dev/flutter/services/TextInputAction.html). +// This class is cloned from `TextInputAction` and must be kept in sync. The cloning is needed +// because importing is not allowed directly. +enum TextInputAction { + /// Logical meaning: There is no relevant input action for the current input + /// source, e.g., [TextField]. + /// + /// Android: Corresponds to Android's "IME_ACTION_NONE". The keyboard setup + /// is decided by the OS. The keyboard will likely show a return key. + /// + /// iOS: iOS does not have a keyboard return type of "none." It is + /// inappropriate to choose this [TextInputAction] when running on iOS. + none, + + /// Logical meaning: Let the OS decide which action is most appropriate. + /// + /// Android: Corresponds to Android's "IME_ACTION_UNSPECIFIED". The OS chooses + /// which keyboard action to display. The decision will likely be a done + /// button or a return key. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDefault". The title displayed in + /// the action button is "return". + unspecified, + + /// Logical meaning: The user is done providing input to a group of inputs + /// (like a form). Some kind of finalization behavior should now take place. + /// + /// Android: Corresponds to Android's "IME_ACTION_DONE". The OS displays a + /// button that represents completion, e.g., a checkmark button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDone". The title displayed in the + /// action button is "Done". + done, + + /// Logical meaning: The user has entered some text that represents a + /// destination, e.g., a restaurant name. The "go" button is intended to take + /// the user to a part of the app that corresponds to this destination. + /// + /// Android: Corresponds to Android's "IME_ACTION_GO". The OS displays a + /// button that represents taking "the user to the target of the text they + /// typed", e.g., a right-facing arrow button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyGo". The title displayed in the + /// action button is "Go". + go, + + /// Logical meaning: Execute a search query. + /// + /// Android: Corresponds to Android's "IME_ACTION_SEARCH". The OS displays a + /// button that represents a search, e.g., a magnifying glass button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeySearch". The title displayed in the + /// action button is "Search". + search, + + /// Logical meaning: Sends something that the user has composed, e.g., an + /// email or a text message. + /// + /// Android: Corresponds to Android's "IME_ACTION_SEND". The OS displays a + /// button that represents sending something, e.g., a paper plane button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeySend". The title displayed in the + /// action button is "Send". + send, + + /// Logical meaning: The user is done with the current input source and wants + /// to move to the next one. + /// + /// Moves the focus to the next focusable item in the same [FocusScope]. + /// + /// Android: Corresponds to Android's "IME_ACTION_NEXT". The OS displays a + /// button that represents moving forward, e.g., a right-facing arrow button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyNext". The title displayed in the + /// action button is "Next". + next, + + /// Logical meaning: The user wishes to return to the previous input source + /// in the group, e.g., a form with multiple [TextField]s. + /// + /// Moves the focus to the previous focusable item in the same [FocusScope]. + /// + /// Android: Corresponds to Android's "IME_ACTION_PREVIOUS". The OS displays a + /// button that represents moving backward, e.g., a left-facing arrow button. + /// + /// iOS: iOS does not have a keyboard return type of "previous." It is + /// inappropriate to choose this [TextInputAction] when running on iOS. + previous, + + /// Logical meaning: In iOS apps, it is common for a "Back" button and + /// "Continue" button to appear at the top of the screen. However, when the + /// keyboard is open, these buttons are often hidden off-screen. Therefore, + /// the purpose of the "Continue" return key on iOS is to make the "Continue" + /// button available when the user is entering text. + /// + /// Historical context aside, [TextInputAction.continueAction] can be used any + /// time that the term "Continue" seems most appropriate for the given action. + /// + /// Android: Android does not have an IME input type of "continue." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyContinue". The title displayed in the + /// action button is "Continue". This action is only available on iOS 9.0+. + /// + /// The reason that this value has "Action" post-fixed to it is because + /// "continue" is a reserved word in Dart, as well as many other languages. + continueAction, + + /// Logical meaning: The user wants to join something, e.g., a wireless + /// network. + /// + /// Android: Android does not have an IME input type of "join." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyJoin". The title displayed in the + /// action button is "Join". + join, + + /// Logical meaning: The user wants routing options, e.g., driving directions. + /// + /// Android: Android does not have an IME input type of "route." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyRoute". The title displayed in the + /// action button is "Route". + route, + + /// Logical meaning: Initiate a call to emergency services. + /// + /// Android: Android does not have an IME input type of "emergencyCall." It is + /// inappropriate to choose this [TextInputAction] when running on Android. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyEmergencyCall". The title displayed + /// in the action button is "Emergency Call". + emergencyCall, + + /// Logical meaning: Insert a newline character in the focused text input, + /// e.g., [TextField]. + /// + /// Android: Corresponds to Android's "IME_ACTION_NONE". The OS displays a + /// button that represents a new line, e.g., a carriage return button. + /// + /// iOS: Corresponds to iOS's "UIReturnKeyDefault". The title displayed in the + /// action button is "return". + /// + /// The term [TextInputAction.newline] exists in Flutter but not in Android + /// or iOS. The reason for introducing this term is so that developers can + /// achieve the common result of inserting new lines without needing to + /// understand the various IME actions on Android and return keys on iOS. + /// Thus, [TextInputAction.newline] is a convenience term that alleviates the + /// need to understand the underlying platforms to achieve this common behavior. + newline, +} diff --git a/packages/flutter_driver/lib/src/driver/driver.dart b/packages/flutter_driver/lib/src/driver/driver.dart index 3f87dd994f..33cc9dee41 100644 --- a/packages/flutter_driver/lib/src/driver/driver.dart +++ b/packages/flutter_driver/lib/src/driver/driver.dart @@ -21,6 +21,7 @@ import '../common/render_tree.dart'; import '../common/request_data.dart'; import '../common/semantics.dart'; import '../common/text.dart'; +import '../common/text_input_action.dart'; import '../common/wait.dart'; import 'timeline.dart'; import 'vmservice_driver.dart'; @@ -512,6 +513,34 @@ abstract class FlutterDriver { await sendCommand(SetTextEntryEmulation(enabled, timeout: timeout)); } + /// Simulate the user posting a text input action. + /// + /// The available action types can be found in [TextInputAction]. The [sendTextInputAction] + /// does not check whether the [TextInputAction] performed is acceptable + /// based on the client arguments of the text input. + /// + /// This can be called even if the [TestTextInput] has not been [TestTextInput.register]ed. + /// + /// Example: + /// {@tool snippet} + /// + /// ```dart + /// test('submit text in a text field', () async { + /// var textField = find.byValueKey('enter-text-field'); + /// await driver.tap(textField); // acquire focus + /// await driver.enterText('Hello!'); // enter text + /// await driver.waitFor(find.text('Hello!')); // verify text appears on UI + /// await driver.sendTextInputAction(TextInputAction.done); // submit text + /// }); + /// ``` + /// {@end-tool} + /// + Future sendTextInputAction(TextInputAction action, + {Duration? timeout}) async { + assert(action != null); + await sendCommand(SendTextInputAction(action, timeout: timeout)); + } + /// Sends a string and returns a string. /// /// This enables generic communication between the driver and the application. diff --git a/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart b/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart index 3888da7841..b8be067aea 100644 --- a/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart +++ b/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart @@ -10,6 +10,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_driver/src/common/error.dart'; import 'package:flutter_driver/src/common/health.dart'; import 'package:flutter_driver/src/common/layer_tree.dart'; +import 'package:flutter_driver/src/common/text_input_action.dart'; import 'package:flutter_driver/src/common/wait.dart'; import 'package:flutter_driver/src/driver/driver.dart'; import 'package:flutter_driver/src/driver/timeline.dart'; @@ -351,6 +352,16 @@ void main() { }); }); + group('sendTextInputAction', () { + test('sends the SendTextInputAction command with action done', () async { + fakeClient.responses['send_text_input_action'] = makeFakeResponse({}); + await driver.sendTextInputAction(TextInputAction.done, timeout: _kTestTimeout); + expect(fakeClient.commandLog, [ + 'ext.flutter.driver {command: send_text_input_action, timeout: $_kSerializedTestTimeout, action: done}', + ]); + }); + }); + group('getLayerTree', () { test('sends the getLayerTree command', () async { fakeClient.responses['get_layer_tree'] = makeFakeResponse({ diff --git a/packages/flutter_driver/test/src/real_tests/text_input_action_test.dart b/packages/flutter_driver/test/src/real_tests/text_input_action_test.dart new file mode 100644 index 0000000000..e1ec84f4cf --- /dev/null +++ b/packages/flutter_driver/test/src/real_tests/text_input_action_test.dart @@ -0,0 +1,19 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_driver/flutter_driver.dart' as flutter_driver; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('flutter_driver.TextInputAction should be sync with TextInputAction', + () { + final List actual = flutter_driver.TextInputAction.values + .map((flutter_driver.TextInputAction action) => action.name) + .toList(); + final List matcher = TextInputAction.values + .map((TextInputAction action) => action.name) + .toList(); + expect(actual, matcher); + }); +}