// 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: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, TestDefaultBinaryMessengerBinding { _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); } } } /// 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}) { _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!); } 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); } }