| // 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 'dart:async'; |
| |
| import 'package:meta/meta.dart'; |
| import 'package:flutter/cupertino.dart'; |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/rendering.dart' show RendererBinding; |
| import 'package:flutter/scheduler.dart'; |
| import 'package:flutter/semantics.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| |
| import '../common/deserialization_factory.dart'; |
| import '../common/error.dart'; |
| import '../common/find.dart'; |
| import '../common/handler_factory.dart'; |
| import '../common/message.dart'; |
| import '_extension_io.dart' if (dart.library.html) '_extension_web.dart'; |
| |
| const String _extensionMethodName = 'driver'; |
| |
| /// Signature for the handler passed to [enableFlutterDriverExtension]. |
| /// |
| /// Messages are described in string form and should return a [Future] which |
| /// eventually completes to a string response. |
| typedef DataHandler = Future<String> Function(String? message); |
| |
| class _DriverBinding extends BindingBase with SchedulerBinding, ServicesBinding, GestureBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding { |
| _DriverBinding(this._handler, this._silenceErrors, this._enableTextEntryEmulation, this.finders, this.commands); |
| |
| final DataHandler? _handler; |
| final bool _silenceErrors; |
| final bool _enableTextEntryEmulation; |
| final List<FinderExtension>? finders; |
| final List<CommandExtension>? commands; |
| |
| @override |
| void initServiceExtensions() { |
| super.initServiceExtensions(); |
| final FlutterDriverExtension extension = FlutterDriverExtension(_handler, _silenceErrors, _enableTextEntryEmulation, finders: finders ?? const <FinderExtension>[], commands: commands ?? const <CommandExtension>[]); |
| registerServiceExtension( |
| name: _extensionMethodName, |
| callback: extension.call, |
| ); |
| if (kIsWeb) { |
| registerWebServiceExtension(extension.call); |
| } |
| } |
| |
| @override |
| BinaryMessenger createBinaryMessenger() { |
| return TestDefaultBinaryMessenger(super.createBinaryMessenger()); |
| } |
| } |
| |
| /// Enables Flutter Driver VM service extension. |
| /// |
| /// This extension is required for tests that use `package:flutter_driver` to |
| /// drive applications from a separate process. In order to allow the driver |
| /// to interact with the application, this method changes the behavior of the |
| /// framework in several ways - including keyboard interaction and text |
| /// editing. Applications intended for release should never include this |
| /// method. |
| /// |
| /// Call this function prior to running your application, e.g. before you call |
| /// `runApp`. |
| /// |
| /// Optionally you can pass a [DataHandler] callback. It will be called if the |
| /// test calls [FlutterDriver.requestData]. |
| /// |
| /// `silenceErrors` will prevent exceptions from being logged. This is useful |
| /// for tests where exceptions are expected. Defaults to false. Any errors |
| /// will still be returned in the `response` field of the result JSON along |
| /// with an `isError` boolean. |
| /// |
| /// The `enableTextEntryEmulation` parameter controls whether the application interacts |
| /// with the system's text entry methods or a mocked out version used by Flutter Driver. |
| /// If it is set to false, [FlutterDriver.enterText] will fail, |
| /// but testing the application with real keyboard input is possible. |
| /// This value may be updated during a test by calling [FlutterDriver.setTextEntryEmulation]. |
| /// |
| /// The `finders` and `commands` parameters are optional and used to add custom |
| /// finders or commands, as in the following example. |
| /// |
| /// ```dart main |
| /// void main() { |
| /// enableFlutterDriverExtension( |
| /// finders: <FinderExtension>[ SomeFinderExtension() ], |
| /// commands: <CommandExtension>[ SomeCommandExtension() ], |
| /// ); |
| /// |
| /// app.main(); |
| /// } |
| /// ``` |
| /// |
| /// ```dart |
| /// driver.sendCommand(SomeCommand(ByValueKey('Button'), 7)); |
| /// ``` |
| /// |
| /// Note: SomeFinder and SomeFinderExtension must be placed in different files |
| /// to avoid `dart:ui` import issue. Imports relative to `dart:ui` can't be |
| /// accessed from host runner, where flutter runtime is not accessible. |
| /// |
| /// ```dart |
| /// class SomeFinder extends SerializableFinder { |
| /// const SomeFinder(this.title); |
| /// |
| /// final String title; |
| /// |
| /// @override |
| /// String get finderType => 'SomeFinder'; |
| /// |
| /// @override |
| /// Map<String, String> serialize() => super.serialize()..addAll(<String, String>{ |
| /// 'title': title, |
| /// }); |
| /// } |
| /// ``` |
| /// |
| /// ```dart |
| /// class SomeFinderExtension extends FinderExtension { |
| /// |
| /// String get finderType => 'SomeFinder'; |
| /// |
| /// SerializableFinder deserialize(Map<String, String> params, DeserializeFinderFactory finderFactory) { |
| /// return SomeFinder(json['title']); |
| /// } |
| /// |
| /// Finder createFinder(SerializableFinder finder, CreateFinderFactory finderFactory) { |
| /// Some someFinder = finder as SomeFinder; |
| /// |
| /// return find.byElementPredicate((Element element) { |
| /// final Widget widget = element.widget; |
| /// if (element.widget is SomeWidget) { |
| /// return element.widget.title == someFinder.title; |
| /// } |
| /// return false; |
| /// }); |
| /// } |
| /// } |
| /// ``` |
| /// |
| /// Note: SomeCommand, SomeResult and SomeCommandExtension must be placed in |
| /// different files to avoid `dart:ui` import issue. Imports relative to `dart:ui` |
| /// can't be accessed from host runner, where flutter runtime is not accessible. |
| /// |
| /// ```dart |
| /// class SomeCommand extends CommandWithTarget { |
| /// SomeCommand(SerializableFinder finder, this.times, {Duration? timeout}) |
| /// : super(finder, timeout: timeout); |
| /// |
| /// SomeCommand.deserialize(Map<String, String> json, DeserializeFinderFactory finderFactory) |
| /// : times = int.parse(json['times']!), |
| /// super.deserialize(json, finderFactory); |
| /// |
| /// @override |
| /// Map<String, String> serialize() { |
| /// return super.serialize()..addAll(<String, String>{'times': '$times'}); |
| /// } |
| /// |
| /// @override |
| /// String get kind => 'SomeCommand'; |
| /// |
| /// final int times; |
| /// } |
| ///``` |
| /// |
| /// ```dart |
| /// class SomeCommandResult extends Result { |
| /// const SomeCommandResult(this.resultParam); |
| /// |
| /// final String resultParam; |
| /// |
| /// @override |
| /// Map<String, dynamic> toJson() { |
| /// return <String, dynamic>{ |
| /// 'resultParam': resultParam, |
| /// }; |
| /// } |
| /// } |
| /// ``` |
| /// |
| /// ```dart |
| /// class SomeCommandExtension extends CommandExtension { |
| /// @override |
| /// String get commandKind => 'SomeCommand'; |
| /// |
| /// @override |
| /// Future<Result> call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { |
| /// final SomeCommand someCommand = command as SomeCommand; |
| /// |
| /// // Deserialize [Finder]: |
| /// final Finder finder = finderFactory.createFinder(stubCommand.finder); |
| /// |
| /// // Wait for [Element]: |
| /// handlerFactory.waitForElement(finder); |
| /// |
| /// // Alternatively, wait for [Element] absence: |
| /// handlerFactory.waitForAbsentElement(finder); |
| /// |
| /// // Submit known [Command]s: |
| /// for (int index = 0; i < someCommand.times; index++) { |
| /// await handlerFactory.handleCommand(Tap(someCommand.finder), prober, finderFactory); |
| /// } |
| /// |
| /// // Alternatively, use [WidgetController]: |
| /// for (int index = 0; i < stubCommand.times; index++) { |
| /// await prober.tap(finder); |
| /// } |
| /// |
| /// return const SomeCommandResult('foo bar'); |
| /// } |
| /// |
| /// @override |
| /// Command deserialize(Map<String, String> params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory) { |
| /// return SomeCommand.deserialize(params, finderFactory); |
| /// } |
| /// } |
| /// ``` |
| /// |
| void enableFlutterDriverExtension({ DataHandler? handler, bool silenceErrors = false, bool enableTextEntryEmulation = true, List<FinderExtension>? finders, List<CommandExtension>? commands}) { |
| assert(WidgetsBinding.instance == null); |
| _DriverBinding(handler, silenceErrors, enableTextEntryEmulation, finders ?? <FinderExtension>[], commands ?? <CommandExtension>[]); |
| assert(WidgetsBinding.instance is _DriverBinding); |
| } |
| |
| /// Signature for functions that handle a command and return a result. |
| typedef CommandHandlerCallback = Future<Result?> Function(Command c); |
| |
| /// Signature for functions that deserialize a JSON map to a command object. |
| typedef CommandDeserializerCallback = Command Function(Map<String, String> params); |
| |
| /// Used to expand the new [Finder]. |
| abstract class FinderExtension { |
| |
| /// Identifies the type of finder to be used by the driver extension. |
| String get finderType; |
| |
| /// Deserializes the finder from JSON generated by [SerializableFinder.serialize]. |
| /// |
| /// Use [finderFactory] to deserialize nested [Finder]s. |
| /// |
| /// See also: |
| /// * [Ancestor], a finder that uses other [Finder]s as parameters. |
| SerializableFinder deserialize(Map<String, String> params, DeserializeFinderFactory finderFactory); |
| |
| /// Signature for functions that run the given finder and return the [Element] |
| /// found, if any, or null otherwise. |
| /// |
| /// Call [finderFactory] to create known, nested [Finder]s from [SerializableFinder]s. |
| Finder createFinder(SerializableFinder finder, CreateFinderFactory finderFactory); |
| } |
| |
| /// Used to expand the new [Command]. |
| /// |
| /// See also: |
| /// * [CommandWithTarget], a base class for [Command]s with [Finder]s. |
| abstract class CommandExtension { |
| |
| /// Identifies the type of command to be used by the driver extension. |
| String get commandKind; |
| |
| /// Deserializes the command from JSON generated by [Command.serialize]. |
| /// |
| /// Use [finderFactory] to deserialize nested [Finder]s. |
| /// Usually used for [CommandWithTarget]s. |
| /// |
| /// Call [commandFactory] to deserialize commands specified as parameters. |
| /// |
| /// See also: |
| /// * [CommandWithTarget], a base class for commands with target finders. |
| /// * [Tap], a command that uses [Finder]s as parameter. |
| Command deserialize(Map<String, String> params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory); |
| |
| /// Calls action for given [command]. |
| /// Returns action [Result]. |
| /// Invoke [prober] functions to perform widget actions. |
| /// Use [finderFactory] to create [Finder]s from [SerializableFinder]. |
| /// Call [handlerFactory] to invoke other [Command]s or [CommandWithTarget]s. |
| /// |
| /// The following example shows invoking nested command with [handlerFactory]. |
| /// |
| /// ```dart |
| /// @override |
| /// Future<Result> call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { |
| /// final StubNestedCommand stubCommand = command as StubNestedCommand; |
| /// for (int index = 0; i < stubCommand.times; index++) { |
| /// await handlerFactory.handleCommand(Tap(stubCommand.finder), prober, finderFactory); |
| /// } |
| /// return const StubCommandResult('stub response'); |
| /// } |
| /// ``` |
| /// |
| /// Check the example below for direct [WidgetController] usage with [prober]: |
| /// |
| /// ```dart |
| /// @override |
| /// Future<Result> call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { |
| /// final StubProberCommand stubCommand = command as StubProberCommand; |
| /// for (int index = 0; i < stubCommand.times; index++) { |
| /// await prober.tap(finderFactory.createFinder(stubCommand.finder)); |
| /// } |
| /// return const StubCommandResult('stub response'); |
| /// } |
| /// ``` |
| Future<Result> call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory); |
| } |
| |
| /// The class that manages communication between a Flutter Driver test and the |
| /// application being remote-controlled, on the application side. |
| /// |
| /// This is not normally used directly. It is instantiated automatically when |
| /// calling [enableFlutterDriverExtension]. |
| @visibleForTesting |
| class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory, DeserializeCommandFactory, CommandHandlerFactory { |
| /// Creates an object to manage a Flutter Driver connection. |
| FlutterDriverExtension( |
| this._requestDataHandler, |
| this._silenceErrors, |
| this._enableTextEntryEmulation, { |
| List<FinderExtension> finders = const <FinderExtension>[], |
| List<CommandExtension> commands = const <CommandExtension>[], |
| }) : assert(finders != null) { |
| if (_enableTextEntryEmulation) { |
| registerTextInput(); |
| } |
| |
| for(final FinderExtension finder in finders) { |
| _finderExtensions[finder.finderType] = finder; |
| } |
| |
| for(final CommandExtension command in commands) { |
| _commandExtensions[command.commandKind] = command; |
| } |
| } |
| |
| final WidgetController _prober = LiveWidgetController(WidgetsBinding.instance!); |
| |
| final DataHandler? _requestDataHandler; |
| |
| final bool _silenceErrors; |
| |
| final bool _enableTextEntryEmulation; |
| |
| void _log(String message) { |
| driverLog('FlutterDriverExtension', message); |
| } |
| |
| final Map<String, FinderExtension> _finderExtensions = <String, FinderExtension>{}; |
| final Map<String, CommandExtension> _commandExtensions = <String, CommandExtension>{}; |
| |
| /// Processes a driver command configured by [params] and returns a result |
| /// as an arbitrary JSON object. |
| /// |
| /// [params] must contain key "command" whose value is a string that |
| /// identifies the kind of the command and its corresponding |
| /// [CommandDeserializerCallback]. Other keys and values are specific to the |
| /// concrete implementation of [Command] and [CommandDeserializerCallback]. |
| /// |
| /// The returned JSON is command specific. Generally the caller deserializes |
| /// the result into a subclass of [Result], but that's not strictly required. |
| @visibleForTesting |
| Future<Map<String, dynamic>> call(Map<String, String> params) async { |
| final String commandKind = params['command']!; |
| try { |
| final Command command = deserializeCommand(params, this); |
| assert(WidgetsBinding.instance!.isRootWidgetAttached || !command.requiresRootWidgetAttached, |
| 'No root widget is attached; have you remembered to call runApp()?'); |
| Future<Result?> responseFuture = handleCommand(command, _prober, this); |
| if (command.timeout != null) |
| responseFuture = responseFuture.timeout(command.timeout ?? Duration.zero); |
| final Result? response = await responseFuture; |
| return _makeResponse(response?.toJson()); |
| } on TimeoutException catch (error, stackTrace) { |
| final String message = 'Timeout while executing $commandKind: $error\n$stackTrace'; |
| _log(message); |
| return _makeResponse(message, isError: true); |
| } catch (error, stackTrace) { |
| final String message = 'Uncaught extension error while executing $commandKind: $error\n$stackTrace'; |
| if (!_silenceErrors) |
| _log(message); |
| return _makeResponse(message, isError: true); |
| } |
| } |
| |
| Map<String, dynamic> _makeResponse(dynamic response, { bool isError = false }) { |
| return <String, dynamic>{ |
| 'isError': isError, |
| 'response': response, |
| }; |
| } |
| |
| @override |
| SerializableFinder deserializeFinder(Map<String, String> json) { |
| final String? finderType = json['finderType']; |
| if (_finderExtensions.containsKey(finderType)) { |
| return _finderExtensions[finderType]!.deserialize(json, this); |
| } |
| |
| return super.deserializeFinder(json); |
| } |
| |
| @override |
| Finder createFinder(SerializableFinder finder) { |
| final String finderType = finder.finderType; |
| if (_finderExtensions.containsKey(finderType)) { |
| return _finderExtensions[finderType]!.createFinder(finder, this); |
| } |
| |
| return super.createFinder(finder); |
| } |
| |
| @override |
| Command deserializeCommand(Map<String, String> params, DeserializeFinderFactory finderFactory) { |
| final String? kind = params['command']; |
| if(_commandExtensions.containsKey(kind)) { |
| return _commandExtensions[kind]!.deserialize(params, finderFactory, this); |
| } |
| |
| return super.deserializeCommand(params, finderFactory); |
| } |
| |
| @override |
| @protected |
| DataHandler? getDataHandler() { |
| return _requestDataHandler; |
| } |
| |
| @override |
| Future<Result?> handleCommand(Command command, WidgetController prober, CreateFinderFactory finderFactory) { |
| final String kind = command.kind; |
| if(_commandExtensions.containsKey(kind)) { |
| return _commandExtensions[kind]!.call(command, prober, finderFactory, this); |
| } |
| |
| return super.handleCommand(command, prober, finderFactory); |
| } |
| } |