diff --git a/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart b/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart index 36e57531c2..ef84b1b3a3 100644 --- a/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart +++ b/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart @@ -20,6 +20,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // Focus on a TextFormField. final Finder finder = find.byKey(const Key('input')); expect(finder, findsOneWidget); @@ -45,6 +48,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // Focus on a TextFormField. final Finder finder = find.byKey(const Key('empty-input')); expect(finder, findsOneWidget); @@ -70,6 +76,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // This text will show no-enter initially. It will have 'enter-pressed' // after `onFieldSubmitted` of TextField is triggered. final Finder textFinder = find.byKey(const Key('text')); @@ -103,6 +112,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // Focus on a TextFormField. final Finder finder = find.byKey(const Key('input')); expect(finder, findsOneWidget); @@ -135,6 +147,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // Focus on a TextFormField. final Finder finder = find.byKey(const Key('input')); expect(finder, findsOneWidget); @@ -182,6 +197,9 @@ void main() { app.main(); await tester.pumpAndSettle(); + // TODO(nurhan): https://github.com/flutter/flutter/issues/51885 + SystemChannels.textInput.setMockMethodCallHandler(null); + // Select something from the selectable text. final Finder finder = find.byKey(const Key('selectable')); expect(finder, findsOneWidget); diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 4ab2b27d02..43e6bf29f2 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -120,11 +120,6 @@ class SystemChannels { /// they apply, so that stale messages referencing past transactions can be /// ignored. /// - /// In debug builds, messages sent with a client ID of -1 are always accepted. - /// This allows tests to smuggle messages without having to mock the engine's - /// text handling (for example, allowing the engine to still handle the text - /// input messages in an integration test). - /// /// The methods described below are wrapped in a more convenient form by the /// [TextInput] and [TextInputConnection] class. /// @@ -157,15 +152,9 @@ class SystemChannels { /// is a transaction identifier. Calls for stale transactions should be ignored. /// /// * `TextInputClient.updateEditingState`: The user has changed the contents - /// of the text control. The second argument is an object with seven keys, - /// in the form expected by [TextEditingValue.fromJSON]. - /// - /// * `TextInputClient.updateEditingStateWithTag`: One or more text controls - /// were autofilled by the platform's autofill service. The first argument - /// (the client ID) is ignored, the second argument is a map of tags to - /// objects in the form expected by [TextEditingValue.fromJSON]. See - /// [AutofillScope.getAutofillClient] for details on the interpretation of - /// the tag. + /// of the text control. The second argument is a [String] containing a + /// JSON-encoded object with seven keys, in the form expected by + /// [TextEditingValue.fromJSON]. /// /// * `TextInputClient.performAction`: The user has triggered an action. The /// second argument is a [String] consisting of the stringification of one @@ -176,8 +165,7 @@ class SystemChannels { /// one. The framework should call `TextInput.setClient` and /// `TextInput.setEditingState` again with its most recent information. If /// there is no existing state on the framework side, the call should - /// fizzle. (This call is made without a client ID; indeed, without any - /// arguments at all.) + /// fizzle. /// /// * `TextInputClient.onConnectionClosed`: The text input connection closed /// on the platform side. For example the application is moved to diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 5bd33587b1..bac572f6ea 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1327,11 +1327,9 @@ class TextInput { final List args = methodCall.arguments as List; - // The updateEditingStateWithTag request (autofill) can come up even to a - // text field that doesn't have a connection. if (method == 'TextInputClient.updateEditingStateWithTag') { - assert(_currentConnection!._client != null); final TextInputClient client = _currentConnection!._client; + assert(client != null); final AutofillScope? scope = client.currentAutofillScope; final Map editingValue = args[1] as Map; for (final String tag in editingValue.keys) { @@ -1345,22 +1343,9 @@ class TextInput { } final int client = args[0] as int; - if (client != _currentConnection!._id) { - // If the client IDs don't match, the incoming message was for a different - // client. - bool debugAllowAnyway = false; - assert(() { - // In debug builds we allow "-1" as a magical client ID that ignores - // this verification step so that tests can always get through, even - // when they are not mocking the engine side of text input. - if (client == -1) - debugAllowAnyway = true; - return true; - }()); - if (!debugAllowAnyway) - return; - } - + // The incoming message was for a different client. + if (client != _currentConnection!._id) + return; switch (method) { case 'TextInputClient.updateEditingState': _currentConnection!._client.updateEditingValue(TextEditingValue.fromJSON(args[1] as Map)); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e321ab2dde..498550e2cf 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2112,7 +2112,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_hasFocus) { _openInputConnection(); } else { - widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged. + widget.focusNode.requestFocus(); } } diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 655c0fd395..4a22d99cd3 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -195,15 +195,9 @@ abstract class TestWidgetsFlutterBinding extends BindingBase /// Called by the test framework at the beginning of a widget test to /// prepare the binding for the next test. - /// - /// If [registerTestTextInput] returns true when this method is called, - /// the [testTextInput] is configured to simulate the keyboard. void reset() { _restorationManager = null; resetGestureBinding(); - testTextInput.reset(); - if (registerTestTextInput) - _testTextInput.register(); } @override @@ -243,8 +237,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase @protected bool get overrideHttpClient => true; - /// Determines whether the binding automatically registers [testTextInput] as - /// a fake keyboard implementation. + /// Determines whether the binding automatically registers [testTextInput]. /// /// Unit tests make use of this to mock out text input communication for /// widgets. An integration test would set this to false, to test real IME @@ -252,19 +245,6 @@ abstract class TestWidgetsFlutterBinding extends BindingBase /// /// [TestTextInput.isRegistered] reports whether the text input mock is /// registered or not. - /// - /// Some of the properties and methods on [testTextInput] are only valid if - /// [registerTestTextInput] returns true when a test starts. If those - /// members are accessed when using a binding that sets this flag to false, - /// they will throw. - /// - /// If this property returns true when a test ends, the [testTextInput] is - /// unregistered. - /// - /// This property should not change the value it returns during the lifetime - /// of the binding. Changing the value of this property risks very confusing - /// behavior as the [TestTextInput] may be inconsistently registered or - /// unregistered. @protected bool get registerTestTextInput => true; @@ -339,6 +319,9 @@ abstract class TestWidgetsFlutterBinding extends BindingBase binding.setupHttpOverrides(); } _testTextInput = TestTextInput(onCleared: _resetFocusedEditable); + if (registerTestTextInput) { + _testTextInput.register(); + } } @override @@ -532,20 +515,12 @@ abstract class TestWidgetsFlutterBinding extends BindingBase TestTextInput get testTextInput => _testTextInput; late TestTextInput _testTextInput; - /// The [State] of the current [EditableText] client of the onscreen keyboard. - /// - /// Setting this property to a new value causes the given [EditableTextState] - /// to focus itself and request the keyboard to establish a - /// [TextInputConnection]. - /// - /// Callers must pump an additional frame after setting this property to - /// complete the focus change. + /// The current client of the onscreen keyboard. Callers must pump + /// an additional frame after setting this property to complete the + /// focus change. /// /// Instead of setting this directly, consider using /// [WidgetTester.showKeyboard]. - // - // TODO(ianh): We should just remove this property and move the call to - // requestKeyboard to the WidgetTester.showKeyboard method. EditableTextState? get focusedEditable => _focusedEditable; EditableTextState? _focusedEditable; set focusedEditable(EditableTextState? value) { @@ -824,8 +799,6 @@ abstract class TestWidgetsFlutterBinding extends BindingBase // alone so that we don't cause more spurious errors. runApp(Container(key: UniqueKey(), child: _postTestMessage)); // Unmount any remaining widgets. await pump(); - if (registerTestTextInput) - _testTextInput.unregister(); invariantTester(); _verifyAutoUpdateGoldensUnset(autoUpdateGoldensBeforeTest && !isBrowser); _verifyReportTestExceptionUnset(reportTestExceptionBeforeTest); diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 63b5979986..c20fd56a27 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -14,18 +14,6 @@ export 'package:flutter/services.dart' show TextEditingValue, TextInputAction; /// /// Typical app tests will not need to use this class directly. /// -/// The [TestWidgetsFlutterBinding] class registers a [TestTextInput] instance -/// ([TestWidgetsFlutterBinding.testTextInput]) as a stub keyboard -/// implementation if its [TestWidgetsFlutterBinding.registerTestTextInput] -/// property returns true when a test starts, and unregisters it when the test -/// ends (unless it ends with a failure). -/// -/// See [register], [unregister], and [isRegistered] for details. -/// -/// The [enterText], [updateEditingValue], [receiveAction], and -/// [closeConnection] methods can be used even when the [TestTextInput] is not -/// registered. All other methods will assert if [isRegistered] is false. -/// /// See also: /// /// * [WidgetTester.enterText], which uses this class to simulate keyboard input. @@ -48,76 +36,58 @@ class TestTextInput { /// The messenger which sends the bytes for this channel, not null. BinaryMessenger get _binaryMessenger => ServicesBinding.instance!.defaultBinaryMessenger; + /// Resets any internal state of this object and calls [register]. + /// + /// This method is invoked by the testing framework between tests. It should + /// not ordinarily be called by tests directly. + void resetAndRegister() { + log.clear(); + editingState = null; + setClientArgs = null; + _client = 0; + _isVisible = false; + register(); + } + /// Installs this object as a mock handler for [SystemChannels.textInput]. + void register() => SystemChannels.textInput.setMockMethodCallHandler(_handleTextInputCall); + + /// Removes this object as a mock handler for [SystemChannels.textInput]. + /// + /// After calling this method, the channel will exchange messages with the + /// Flutter engine. Use this with [FlutterDriver] tests that need to display + /// on-screen keyboard provided by the operating system. + void unregister() => SystemChannels.textInput.setMockMethodCallHandler(null); + /// Log for method calls. /// /// For all registered channels, handled calls are added to the list. Can /// be cleaned using `log.clear()`. final List log = []; - /// Installs this object as a mock handler for [SystemChannels.textInput]. - /// - /// Called by the binding at the top of a test when - /// [TestWidgetsFlutterBinding.registerTestTextInput] is true. - void register() => SystemChannels.textInput.setMockMethodCallHandler(_handleTextInputCall); - - /// Removes this object as a mock handler for [SystemChannels.textInput]. - /// - /// After calling this method, the channel will exchange messages with the - /// Flutter engine instead of the stub. - /// - /// Called by the binding at the end of a (successful) test when - /// [TestWidgetsFlutterBinding.registerTestTextInput] is true. - void unregister() => SystemChannels.textInput.setMockMethodCallHandler(null); - /// Whether this [TestTextInput] is registered with [SystemChannels.textInput]. /// - /// The binding uses the [register] and [unregister] methods to control this - /// value when [TestWidgetsFlutterBinding.registerTestTextInput] is true. + /// Use [register] and [unregister] methods to control this value. bool get isRegistered => SystemChannels.textInput.checkMockMethodCallHandler(_handleTextInputCall); - int? _client; - /// Whether there are any active clients listening to text input. bool get hasAnyClients { assert(isRegistered); - return _client != null && _client! > 0; + return _client > 0; } - /// The last set of arguments supplied to the `TextInput.setClient` and - /// `TextInput.updateConfig` methods of this stub implementation. + int _client = 0; + + /// Arguments supplied to the TextInput.setClient method call. Map? setClientArgs; /// The last set of arguments that [TextInputConnection.setEditingState] sent - /// to this stub implementation (i.e. the arguments set to - /// `TextInput.setEditingState`). + /// to the embedder. /// /// This is a map representation of a [TextEditingValue] object. For example, /// it will have a `text` entry whose value matches the most recent /// [TextEditingValue.text] that was sent to the embedder. Map? editingState; - /// Whether the onscreen keyboard is visible to the user. - /// - /// Specifically, this reflects the last call to `TextInput.show` or - /// `TextInput.hide` received by the stub implementation. - bool get isVisible { - assert(isRegistered); - return _isVisible; - } - bool _isVisible = false; - - /// Resets any internal state of this object. - /// - /// This method is invoked by the testing framework between tests. It should - /// not ordinarily be called by tests directly. - void reset() { - log.clear(); - _client = null; - setClientArgs = null; - editingState = null; - _isVisible = false; - } - Future _handleTextInputCall(MethodCall methodCall) async { log.add(methodCall); switch (methodCall.method) { @@ -129,7 +99,7 @@ class TestTextInput { setClientArgs = methodCall.arguments as Map; break; case 'TextInput.clearClient': - _client = null; + _client = 0; _isVisible = false; onCleared?.call(); break; @@ -145,69 +115,87 @@ class TestTextInput { } } - /// Simulates the user hiding the onscreen keyboard. - /// - /// This does nothing but set the internal flag. - void hide() { + /// Whether the onscreen keyboard is visible to the user. + bool get isVisible { assert(isRegistered); - _isVisible = false; - } - - /// Simulates the user typing the given text. - /// - /// Calling this method replaces the content of the connected input field with - /// `text`, and places the caret at the end of the text. - /// - /// This can be called even if the [TestTextInput] has not been [register]ed. - /// - /// If this is used to inject text when there is a real IME connection, for - /// example when using the [integration_test] library, there is a risk that - /// the real IME will become confused as to the current state of input. - void enterText(String text) { - updateEditingValue(TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - )); + return _isVisible; } + bool _isVisible = false; /// Simulates the user changing the [TextEditingValue] to the given value. - /// - /// This can be called even if the [TestTextInput] has not been [register]ed. - /// - /// If this is used to inject text when there is a real IME connection, for - /// example when using the [integration_test] library, there is a risk that - /// the real IME will become confused as to the current state of input. void updateEditingValue(TextEditingValue value) { + assert(isRegistered); + // Not using the `expect` function because in the case of a FlutterDriver + // test this code does not run in a package:test test zone. + if (_client == 0) + throw TestFailure('Tried to use TestTextInput with no keyboard attached. You must use WidgetTester.showKeyboard() first.'); _binaryMessenger.handlePlatformMessage( SystemChannels.textInput.name, SystemChannels.textInput.codec.encodeMethodCall( MethodCall( 'TextInputClient.updateEditingState', - [_client ?? -1, value.toJSON()], + [_client, value.toJSON()], ), ), (ByteData? data) { /* response from framework is discarded */ }, ); } + /// Simulates the user closing the text input connection. + /// + /// For example: + /// - User pressed the home button and sent the application to background. + /// - User closed the virtual keyboard. + void closeConnection() { + assert(isRegistered); + // Not using the `expect` function because in the case of a FlutterDriver + // test this code does not run in a package:test test zone. + if (_client == 0) + throw TestFailure('Tried to use TestTextInput with no keyboard attached. You must use WidgetTester.showKeyboard() first.'); + _binaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.onConnectionClosed', + [_client,] + ), + ), + (ByteData? data) { /* response from framework is discarded */ }, + ); + } + + /// Simulates the user typing the given text. + /// + /// Calling this method replaces the content of the connected input field with + /// `text`, and places the caret at the end of the text. + void enterText(String text) { + assert(isRegistered); + updateEditingValue(TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + )); + } + /// Simulates the user pressing one of the [TextInputAction] buttons. /// Does not check that the [TextInputAction] performed is an acceptable one /// based on the `inputAction` [setClientArgs]. - /// - /// This can be called even if the [TestTextInput] has not been [register]ed. - /// - /// If this is used to inject an action when there is a real IME connection, - /// for example when using the [integration_test] library, there is a risk - /// that the real IME will become confused as to the current state of input. Future receiveAction(TextInputAction action) async { + assert(isRegistered); return TestAsyncUtils.guard(() { + // Not using the `expect` function because in the case of a FlutterDriver + // test this code does not run in a package:test test zone. + if (_client == 0) { + throw TestFailure('Tried to use TestTextInput with no keyboard attached. You must use WidgetTester.showKeyboard() first.'); + } + final Completer completer = Completer(); + _binaryMessenger.handlePlatformMessage( SystemChannels.textInput.name, SystemChannels.textInput.codec.encodeMethodCall( MethodCall( 'TextInputClient.performAction', - [_client ?? -1, action.toString()], + [_client, action.toString()], ), ), (ByteData? data) { @@ -231,28 +219,9 @@ class TestTextInput { }); } - /// Simulates the user closing the text input connection. - /// - /// For example: - /// - /// * User pressed the home button and sent the application to background. - /// * User closed the virtual keyboard. - /// - /// This can be called even if the [TestTextInput] has not been [register]ed. - /// - /// If this is used to inject text when there is a real IME connection, for - /// example when using the [integration_test] library, there is a risk that - /// the real IME will become confused as to the current state of input. - void closeConnection() { - _binaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( - MethodCall( - 'TextInputClient.onConnectionClosed', - [_client ?? -1], - ), - ), - (ByteData? data) { /* response from framework is discarded */ }, - ); + /// Simulates the user hiding the onscreen keyboard. + void hide() { + assert(isRegistered); + _isVisible = false; } } diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 224bea5fec..e5ca5d920f 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -149,6 +149,7 @@ void testWidgets( () async { binding.reset(); debugResetSemanticsIdCounter(); + tester.resetTestTextInput(); Object? memento; try { memento = await variant.setUp(value); @@ -1001,14 +1002,19 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker /// /// Typical app tests will not need to use this value. To add text to widgets /// like [TextField] or [TextFormField], call [enterText]. - /// - /// Some of the properties and methods on this value are only valid if the - /// binding's [TestWidgetsFlutterBinding.registerTestTextInput] flag is set to - /// true as a test is starting (meaning that the keyboard is to be simulated - /// by the test framework). If those members are accessed when using a binding - /// that sets this flag to false, they will throw. TestTextInput get testTextInput => binding.testTextInput; + /// Ensures that [testTextInput] is registered and [TestTextInput.log] is + /// reset. + /// + /// This is called by the testing framework before test runs, so that if a + /// previous test has set its own handler on [SystemChannels.textInput], the + /// [testTextInput] regains control and the log is fresh for the new test. + /// It should not typically need to be called by tests. + void resetTestTextInput() { + testTextInput.resetAndRegister(); + } + /// Give the text input widget specified by [finder] the focus, as if the /// onscreen keyboard had appeared. /// @@ -1029,9 +1035,6 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker matchRoot: true, ), ); - // Setting focusedEditable causes the binding to call requestKeyboard() - // on the EditableTextState, which itself eventually calls TextInput.attach - // to establish the connection. binding.focusedEditable = editable; await pump(); }); @@ -1049,12 +1052,6 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker /// /// To just give [finder] the focus without entering any text, /// see [showKeyboard]. - /// - /// To enter text into other widgets (e.g. a custom widget that maintains a - /// TextInputConnection the way that a [EditableText] does), first ensure that - /// that widget has an open connection (e.g. by using [tap] to to focus it), - /// then call `testTextInput.enterText` directly (see - /// [TestTextInput.enterText]). Future enterText(Finder finder, String text) async { return TestAsyncUtils.guard(() async { await showKeyboard(finder); diff --git a/packages/flutter_test/test/bindings_test.dart b/packages/flutter_test/test/bindings_test.dart index bc98524c79..15e14318c3 100644 --- a/packages/flutter_test/test/bindings_test.dart +++ b/packages/flutter_test/test/bindings_test.dart @@ -11,8 +11,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:test_api/test_api.dart' as test_package; void main() { - final AutomatedTestWidgetsFlutterBinding binding = AutomatedTestWidgetsFlutterBinding(); - group(TestViewConfiguration, () { test('is initialized with top-level window if one is not provided', () { // The code below will throw without the default. @@ -22,32 +20,15 @@ void main() { group(AutomatedTestWidgetsFlutterBinding, () { test('allows setting defaultTestTimeout to 5 minutes', () { + final AutomatedTestWidgetsFlutterBinding binding = AutomatedTestWidgetsFlutterBinding(); binding.defaultTestTimeout = const test_package.Timeout(Duration(minutes: 5)); expect(binding.defaultTestTimeout.duration, const Duration(minutes: 5)); }); }); - // The next three tests must run in order -- first using `test`, then `testWidgets`, then `test` again. - - int order = 0; - test('Initializes httpOverrides and testTextInput', () async { - assert(order == 0); - expect(binding.testTextInput, isNotNull); - expect(binding.testTextInput.isRegistered, isFalse); + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding; + expect(binding.testTextInput.isRegistered, true); expect(HttpOverrides.current, isNotNull); - order += 1; - }); - - testWidgets('Registers testTextInput', (WidgetTester tester) async { - assert(order == 1); - expect(tester.testTextInput.isRegistered, isTrue); - order += 1; - }); - - test('Unregisters testTextInput', () async { - assert(order == 2); - expect(binding.testTextInput.isRegistered, isFalse); - order += 1; }); }