445 lines
16 KiB
Dart
445 lines
16 KiB
Dart
// 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);
|
|
}
|
|
}
|