| // Copyright 2016 The Chromium 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/gestures.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/rendering.dart' show RendererBinding; |
| import 'package:flutter/scheduler.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| |
| import 'error.dart'; |
| import 'find.dart'; |
| import 'frame_sync.dart'; |
| import 'gesture.dart'; |
| import 'health.dart'; |
| import 'input.dart'; |
| import 'message.dart'; |
| import 'render_tree.dart'; |
| |
| const String _extensionMethodName = 'driver'; |
| const String _extensionMethod = 'ext.flutter.$_extensionMethodName'; |
| |
| class _DriverBinding extends WidgetsFlutterBinding { // TODO(ianh): refactor so we're not extending a concrete binding |
| @override |
| void initServiceExtensions() { |
| super.initServiceExtensions(); |
| final FlutterDriverExtension extension = new FlutterDriverExtension(); |
| registerServiceExtension( |
| name: _extensionMethodName, |
| callback: 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. |
| /// |
| /// Call this function prior to running your application, e.g. before you call |
| /// `runApp`. |
| void enableFlutterDriverExtension() { |
| assert(WidgetsBinding.instance == null); |
| new _DriverBinding(); |
| assert(WidgetsBinding.instance is _DriverBinding); |
| } |
| |
| /// Handles a command and returns a result. |
| typedef Future<Result> CommandHandlerCallback(Command c); |
| |
| /// Deserializes JSON map to a command object. |
| typedef Command CommandDeserializerCallback(Map<String, String> params); |
| |
| /// Runs the finder and returns the [Element] found, or `null`. |
| typedef Finder FinderConstructor(SerializableFinder finder); |
| |
| @visibleForTesting |
| class FlutterDriverExtension { |
| static final Logger _log = new Logger('FlutterDriverExtension'); |
| |
| FlutterDriverExtension() { |
| _commandHandlers.addAll(<String, CommandHandlerCallback>{ |
| 'get_health': _getHealth, |
| 'get_render_tree': _getRenderTree, |
| 'tap': _tap, |
| 'get_text': _getText, |
| 'set_frame_sync': _setFrameSync, |
| 'scroll': _scroll, |
| 'scrollIntoView': _scrollIntoView, |
| 'setInputText': _setInputText, |
| 'submitInputText': _submitInputText, |
| 'waitFor': _waitFor, |
| 'waitUntilNoTransientCallbacks': _waitUntilNoTransientCallbacks, |
| }); |
| |
| _commandDeserializers.addAll(<String, CommandDeserializerCallback>{ |
| 'get_health': (Map<String, String> params) => new GetHealth.deserialize(params), |
| 'get_render_tree': (Map<String, String> params) => new GetRenderTree.deserialize(params), |
| 'tap': (Map<String, String> params) => new Tap.deserialize(params), |
| 'get_text': (Map<String, String> params) => new GetText.deserialize(params), |
| 'set_frame_sync': (Map<String, String> params) => new SetFrameSync.deserialize(params), |
| 'scroll': (Map<String, String> params) => new Scroll.deserialize(params), |
| 'scrollIntoView': (Map<String, String> params) => new ScrollIntoView.deserialize(params), |
| 'setInputText': (Map<String, String> params) => new SetInputText.deserialize(params), |
| 'submitInputText': (Map<String, String> params) => new SubmitInputText.deserialize(params), |
| 'waitFor': (Map<String, String> params) => new WaitFor.deserialize(params), |
| 'waitUntilNoTransientCallbacks': (Map<String, String> params) => new WaitUntilNoTransientCallbacks.deserialize(params), |
| }); |
| |
| _finders.addAll(<String, FinderConstructor>{ |
| 'ByText': _createByTextFinder, |
| 'ByTooltipMessage': _createByTooltipMessageFinder, |
| 'ByValueKey': _createByValueKeyFinder, |
| }); |
| } |
| |
| final WidgetController _prober = new WidgetController(WidgetsBinding.instance); |
| final Map<String, CommandHandlerCallback> _commandHandlers = <String, CommandHandlerCallback>{}; |
| final Map<String, CommandDeserializerCallback> _commandDeserializers = <String, CommandDeserializerCallback>{}; |
| final Map<String, FinderConstructor> _finders = <String, FinderConstructor>{}; |
| |
| /// With [_frameSync] enabled, Flutter Driver will wait to perform an action |
| /// until there are no pending frames in the app under test. |
| bool _frameSync = true; |
| |
| /// 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. |
| Future<Map<String, dynamic>> call(Map<String, String> params) async { |
| final String commandKind = params['command']; |
| try { |
| final CommandHandlerCallback commandHandler = _commandHandlers[commandKind]; |
| final CommandDeserializerCallback commandDeserializer = |
| _commandDeserializers[commandKind]; |
| if (commandHandler == null || commandDeserializer == null) |
| throw 'Extension $_extensionMethod does not support command $commandKind'; |
| final Command command = commandDeserializer(params); |
| final Result response = await commandHandler(command).timeout(command.timeout); |
| return _makeResponse(response?.toJson()); |
| } on TimeoutException catch (error, stackTrace) { |
| final String msg = 'Timeout while executing $commandKind: $error\n$stackTrace'; |
| _log.error(msg); |
| return _makeResponse(msg, isError: true); |
| } catch (error, stackTrace) { |
| final String msg = 'Uncaught extension error while executing $commandKind: $error\n$stackTrace'; |
| _log.error(msg); |
| return _makeResponse(msg, isError: true); |
| } |
| } |
| |
| Map<String, dynamic> _makeResponse(dynamic response, {bool isError: false}) { |
| return <String, dynamic>{ |
| 'isError': isError, |
| 'response': response, |
| }; |
| } |
| |
| Future<Health> _getHealth(Command command) async => new Health(HealthStatus.ok); |
| |
| Future<RenderTree> _getRenderTree(Command command) async { |
| return new RenderTree(RendererBinding.instance?.renderView?.toStringDeep()); |
| } |
| |
| // Waits until at the end of a frame the provided [condition] is [true]. |
| Future<Null> _waitUntilFrame(bool condition(), [Completer<Null> completer]) { |
| completer ??= new Completer<Null>(); |
| if (!condition()) { |
| SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { |
| _waitUntilFrame(condition, completer); |
| }); |
| } else { |
| completer.complete(); |
| } |
| return completer.future; |
| } |
| |
| /// Runs `finder` repeatedly until it finds one or more [Element]s. |
| Future<Finder> _waitForElement(Finder finder) async { |
| if (_frameSync) |
| await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); |
| |
| await _waitUntilFrame(() => finder.precache()); |
| |
| if (_frameSync) |
| await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); |
| |
| return finder; |
| } |
| |
| Finder _createByTextFinder(ByText arguments) { |
| return find.text(arguments.text); |
| } |
| |
| Finder _createByTooltipMessageFinder(ByTooltipMessage arguments) { |
| return find.byElementPredicate((Element element) { |
| final Widget widget = element.widget; |
| if (widget is Tooltip) |
| return widget.message == arguments.text; |
| return false; |
| }, description: 'widget with text tooltip "${arguments.text}"'); |
| } |
| |
| Finder _createByValueKeyFinder(ByValueKey arguments) { |
| switch (arguments.keyValueType) { |
| case 'int': |
| return find.byKey(new ValueKey<int>(arguments.keyValue)); |
| case 'String': |
| return find.byKey(new ValueKey<String>(arguments.keyValue)); |
| default: |
| throw 'Unsupported ByValueKey type: ${arguments.keyValueType}'; |
| } |
| } |
| |
| Finder _createFinder(SerializableFinder finder) { |
| final FinderConstructor constructor = _finders[finder.finderType]; |
| |
| if (constructor == null) |
| throw 'Unsupported finder type: ${finder.finderType}'; |
| |
| return constructor(finder); |
| } |
| |
| Future<TapResult> _tap(Command command) async { |
| final Tap tapCommand = command; |
| await _prober.tap(await _waitForElement(_createFinder(tapCommand.finder))); |
| return new TapResult(); |
| } |
| |
| Future<WaitForResult> _waitFor(Command command) async { |
| final WaitFor waitForCommand = command; |
| if ((await _waitForElement(_createFinder(waitForCommand.finder))).evaluate().isNotEmpty) |
| return new WaitForResult(); |
| else |
| return null; |
| } |
| |
| Future<Null> _waitUntilNoTransientCallbacks(Command command) async { |
| if (SchedulerBinding.instance.transientCallbackCount != 0) |
| await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); |
| } |
| |
| Future<ScrollResult> _scroll(Command command) async { |
| final Scroll scrollCommand = command; |
| final Finder target = await _waitForElement(_createFinder(scrollCommand.finder)); |
| final int totalMoves = scrollCommand.duration.inMicroseconds * scrollCommand.frequency ~/ Duration.MICROSECONDS_PER_SECOND; |
| final Offset delta = new Offset(scrollCommand.dx, scrollCommand.dy) / totalMoves.toDouble(); |
| final Duration pause = scrollCommand.duration ~/ totalMoves; |
| final Offset startLocation = _prober.getCenter(target); |
| Offset currentLocation = startLocation; |
| final TestPointer pointer = new TestPointer(1); |
| final HitTestResult hitTest = new HitTestResult(); |
| |
| _prober.binding.hitTest(hitTest, startLocation); |
| _prober.binding.dispatchEvent(pointer.down(startLocation), hitTest); |
| await new Future<Null>.value(); // so that down and move don't happen in the same microtask |
| for (int moves = 0; moves < totalMoves; moves++) { |
| currentLocation = currentLocation + delta; |
| _prober.binding.dispatchEvent(pointer.move(currentLocation), hitTest); |
| await new Future<Null>.delayed(pause); |
| } |
| _prober.binding.dispatchEvent(pointer.up(), hitTest); |
| |
| return new ScrollResult(); |
| } |
| |
| Future<ScrollResult> _scrollIntoView(Command command) async { |
| final ScrollIntoView scrollIntoViewCommand = command; |
| final Finder target = await _waitForElement(_createFinder(scrollIntoViewCommand.finder)); |
| await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: scrollIntoViewCommand.alignment ?? 0.0); |
| return new ScrollResult(); |
| } |
| |
| Finder _findEditableText(SerializableFinder finder) { |
| return find.descendant(of: _createFinder(finder), matching: find.byType(EditableText)); |
| } |
| |
| EditableTextState _getEditableTextState(Finder finder) { |
| final StatefulElement element = finder.evaluate().single; |
| return element.state; |
| } |
| |
| Future<SetInputTextResult> _setInputText(Command command) async { |
| final SetInputText setInputTextCommand = command; |
| final Finder target = await _waitForElement(_findEditableText(setInputTextCommand.finder)); |
| final EditableTextState editable = _getEditableTextState(target); |
| editable.updateEditingValue(new TextEditingValue(text: setInputTextCommand.text)); |
| return new SetInputTextResult(); |
| } |
| |
| Future<SubmitInputTextResult> _submitInputText(Command command) async { |
| final SubmitInputText submitInputTextCommand = command; |
| final Finder target = await _waitForElement(_findEditableText(submitInputTextCommand.finder)); |
| final EditableTextState editable = _getEditableTextState(target); |
| editable.performAction(TextInputAction.done); |
| return new SubmitInputTextResult(editable.widget.controller.value.text); |
| } |
| |
| Future<GetTextResult> _getText(Command command) async { |
| final GetText getTextCommand = command; |
| final Finder target = await _waitForElement(_createFinder(getTextCommand.finder)); |
| // TODO(yjbanov): support more ways to read text |
| final Text text = target.evaluate().single.widget; |
| return new GetTextResult(text.data); |
| } |
| |
| Future<SetFrameSyncResult> _setFrameSync(Command command) async { |
| final SetFrameSync setFrameSyncCommand = command; |
| _frameSync = setFrameSyncCommand.enabled; |
| return new SetFrameSyncResult(); |
| } |
| } |