| // 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 'dart:convert'; |
| import 'dart:developer'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter_test/src/instrumentation.dart'; |
| import 'package:flutter_test/src/test_pointer.dart'; |
| |
| import 'error.dart'; |
| import 'find.dart'; |
| import 'gesture.dart'; |
| import 'health.dart'; |
| import 'message.dart'; |
| import 'retry.dart'; |
| |
| const String _extensionMethod = 'ext.flutter_driver'; |
| const Duration _kDefaultTimeout = const Duration(seconds: 5); |
| const Duration _kDefaultPauseBetweenRetries = const Duration(milliseconds: 160); |
| |
| bool _flutterDriverExtensionEnabled = false; |
| |
| /// 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() { |
| if (_flutterDriverExtensionEnabled) |
| return; |
| FlutterDriverExtension extension = new FlutterDriverExtension(); |
| registerExtension(_extensionMethod, (String methodName, Map<String, String> params) { |
| return extension.call(params); |
| }); |
| _flutterDriverExtensionEnabled = true; |
| } |
| |
| /// Handles a command and returns a result. |
| typedef Future<R> CommandHandlerCallback<R extends Result>(Command c); |
| |
| /// Deserializes JSON map to a command object. |
| typedef Command CommandDeserializerCallback(Map<String, String> params); |
| |
| class FlutterDriverExtension { |
| static final Logger _log = new Logger('FlutterDriverExtension'); |
| |
| FlutterDriverExtension() { |
| _commandHandlers = { |
| 'get_health': getHealth, |
| 'find': find, |
| 'tap': tap, |
| 'get_text': getText, |
| 'scroll': scroll, |
| }; |
| |
| _commandDeserializers = { |
| 'get_health': GetHealth.deserialize, |
| 'find': Find.deserialize, |
| 'tap': Tap.deserialize, |
| 'get_text': GetText.deserialize, |
| 'scroll': Scroll.deserialize, |
| }; |
| } |
| |
| final Instrumentation prober = new Instrumentation(); |
| |
| Map<String, CommandHandlerCallback> _commandHandlers = |
| <String, CommandHandlerCallback>{}; |
| |
| Map<String, CommandDeserializerCallback> _commandDeserializers = |
| <String, CommandDeserializerCallback>{}; |
| |
| Future<ServiceExtensionResponse> call(Map<String, String> params) async { |
| try { |
| String commandKind = params['command']; |
| CommandHandlerCallback commandHandler = _commandHandlers[commandKind]; |
| CommandDeserializerCallback commandDeserializer = |
| _commandDeserializers[commandKind]; |
| |
| if (commandHandler == null || commandDeserializer == null) { |
| return new ServiceExtensionResponse.error( |
| ServiceExtensionResponse.kInvalidParams, |
| 'Extension $_extensionMethod does not support command $commandKind' |
| ); |
| } |
| |
| Command command = commandDeserializer(params); |
| return commandHandler(command).then((Result result) { |
| return new ServiceExtensionResponse.result(JSON.encode(result.toJson())); |
| }, onError: (e, s) { |
| _log.warning('$e:\n$s'); |
| return new ServiceExtensionResponse.error(ServiceExtensionResponse.kExtensionError, '$e'); |
| }); |
| } catch(error, stackTrace) { |
| String message = 'Uncaught extension error: $error\n$stackTrace'; |
| _log.error(message); |
| return new ServiceExtensionResponse.error( |
| ServiceExtensionResponse.kExtensionError, message); |
| } |
| } |
| |
| Future<Health> getHealth(GetHealth command) async => new Health(HealthStatus.ok); |
| |
| Future<ObjectRef> find(Find command) async { |
| SearchSpecification searchSpec = command.searchSpec; |
| switch(searchSpec.runtimeType) { |
| case ByValueKey: return findByValueKey(searchSpec); |
| case ByTooltipMessage: return findByTooltipMessage(searchSpec); |
| case ByText: return findByText(searchSpec); |
| } |
| throw new DriverError('Unsupported search specification type ${searchSpec.runtimeType}'); |
| } |
| |
| /// Runs object [locator] repeatedly until it returns a non-`null` value. |
| /// |
| /// [descriptionGetter] describes the object to be waited for. It is used in |
| /// the warning printed should timeout happen. |
| Future<ObjectRef> _waitForObject(String descriptionGetter(), Object locator()) async { |
| Object object = await retry(locator, _kDefaultTimeout, _kDefaultPauseBetweenRetries, predicate: (object) { |
| return object != null; |
| }).catchError((dynamic error, stackTrace) { |
| _log.warning('Timed out waiting for ${descriptionGetter()}'); |
| return null; |
| }); |
| |
| ObjectRef elemRef = object != null |
| ? new ObjectRef(_registerObject(object)) |
| : new ObjectRef.notFound(); |
| return new Future.value(elemRef); |
| } |
| |
| Future<ObjectRef> findByValueKey(ByValueKey byKey) async { |
| return _waitForObject( |
| () => 'element with key "${byKey.keyValue}" of type ${byKey.keyValueType}', |
| () { |
| return prober.findElementByKey(new ValueKey<dynamic>(byKey.keyValue)); |
| } |
| ); |
| } |
| |
| Future<ObjectRef> findByTooltipMessage(ByTooltipMessage byTooltipMessage) async { |
| return _waitForObject( |
| () => 'tooltip with message "${byTooltipMessage.text}" on it', |
| () { |
| return prober.findElement((Element element) { |
| Widget widget = element.widget; |
| |
| if (widget is Tooltip) |
| return widget.message == byTooltipMessage.text; |
| |
| return false; |
| }); |
| } |
| ); |
| } |
| |
| Future<ObjectRef> findByText(ByText byText) async { |
| return await _waitForObject( |
| () => 'text "${byText.text}"', |
| () { |
| return prober.findText(byText.text); |
| }); |
| } |
| |
| Future<TapResult> tap(Tap command) async { |
| Element target = await _dereferenceOrDie(command.targetRef); |
| prober.tap(target); |
| return new TapResult(); |
| } |
| |
| Future<ScrollResult> scroll(Scroll command) async { |
| Element target = await _dereferenceOrDie(command.targetRef); |
| final int totalMoves = command.duration.inMicroseconds * command.frequency ~/ Duration.MICROSECONDS_PER_SECOND; |
| Offset delta = new Offset(command.dx, command.dy) / totalMoves.toDouble(); |
| Duration pause = command.duration ~/ totalMoves; |
| Point startLocation = prober.getCenter(target); |
| Point currentLocation = startLocation; |
| TestPointer pointer = new TestPointer(1); |
| HitTestResult hitTest = new HitTestResult(); |
| |
| prober.binding.hitTest(hitTest, startLocation); |
| prober.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.dispatchEvent(pointer.move(currentLocation), hitTest); |
| await new Future<Null>.delayed(pause); |
| } |
| prober.dispatchEvent(pointer.up(), hitTest); |
| |
| return new ScrollResult(); |
| } |
| |
| Future<GetTextResult> getText(GetText command) async { |
| Element target = await _dereferenceOrDie(command.targetRef); |
| // TODO(yjbanov): support more ways to read text |
| Text text = target.widget; |
| return new GetTextResult(text.data); |
| } |
| |
| int _refCounter = 1; |
| final Map<String, Object> _objectRefs = <String, Object>{}; |
| String _registerObject(Object obj) { |
| if (obj == null) |
| throw new ArgumentError('Cannot register null object'); |
| String refKey = '${_refCounter++}'; |
| _objectRefs[refKey] = obj; |
| return refKey; |
| } |
| |
| dynamic _dereference(String reference) => _objectRefs[reference]; |
| |
| Future<dynamic> _dereferenceOrDie(String reference) { |
| Element object = _dereference(reference); |
| |
| if (object == null) |
| return new Future.error('Object reference not found ($reference).'); |
| |
| return new Future.value(object); |
| } |
| } |