From b0e45594595ff767cac133fe2c7a6e84810f769f Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Mon, 8 Feb 2016 09:30:20 -0800 Subject: [PATCH] add flutter_driver package This commit contains: - FlutterDriver API for e2e tests usable in conjunction with package:test - FlutterDriverExtension to be enabled by the application in order to allow an external agent to connect to it and drive user interactions and probe into the element tree - initial implementations of tap, findByValueKey and getText commands (to be expanded in future PRs) --- .../flutter_driver/lib/driver_extension.dart | 27 +++ .../flutter_driver/lib/flutter_driver.dart | 38 ++++ packages/flutter_driver/lib/src/driver.dart | 176 +++++++++++++++ packages/flutter_driver/lib/src/error.dart | 81 +++++++ .../flutter_driver/lib/src/extension.dart | 137 ++++++++++++ packages/flutter_driver/lib/src/find.dart | 82 +++++++ packages/flutter_driver/lib/src/gesture.dart | 25 +++ packages/flutter_driver/lib/src/health.dart | 54 +++++ packages/flutter_driver/lib/src/message.dart | 75 +++++++ packages/flutter_driver/pubspec.yaml | 24 ++ .../test/flutter_driver_test.dart | 208 ++++++++++++++++++ travis/test.sh | 1 + 12 files changed, 928 insertions(+) create mode 100644 packages/flutter_driver/lib/driver_extension.dart create mode 100644 packages/flutter_driver/lib/flutter_driver.dart create mode 100644 packages/flutter_driver/lib/src/driver.dart create mode 100644 packages/flutter_driver/lib/src/error.dart create mode 100644 packages/flutter_driver/lib/src/extension.dart create mode 100644 packages/flutter_driver/lib/src/find.dart create mode 100644 packages/flutter_driver/lib/src/gesture.dart create mode 100644 packages/flutter_driver/lib/src/health.dart create mode 100644 packages/flutter_driver/lib/src/message.dart create mode 100644 packages/flutter_driver/pubspec.yaml create mode 100644 packages/flutter_driver/test/flutter_driver_test.dart diff --git a/packages/flutter_driver/lib/driver_extension.dart b/packages/flutter_driver/lib/driver_extension.dart new file mode 100644 index 0000000000..15d3eae3f9 --- /dev/null +++ b/packages/flutter_driver/lib/driver_extension.dart @@ -0,0 +1,27 @@ +// 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. + +/// This library provides a Dart VM service extension that is required for +/// tests that use `package:flutter_driver` to drive applications from a +/// separate process, similar to Selenium (web), Espresso (Android) and UI +/// Automation (iOS). +/// +/// The extension must be installed in the same process (isolate) with your +/// application. +/// +/// To enable the extension call [enableFlutterDriverExtension] early in your +/// program, prior to running your application, e.g. before you call `runApp`. +/// +/// Example: +/// +/// import 'package:flutter/material.dart'; +/// import 'package:flutter_driver/driver_extension.dart'; +/// +/// main() { +/// enableFlutterDriverExtension(); +/// runApp(new ExampleApp()); +/// } +library flutter_driver_extension; + +export 'src/extension.dart' show enableFlutterDriverExtension; diff --git a/packages/flutter_driver/lib/flutter_driver.dart b/packages/flutter_driver/lib/flutter_driver.dart new file mode 100644 index 0000000000..de554be257 --- /dev/null +++ b/packages/flutter_driver/lib/flutter_driver.dart @@ -0,0 +1,38 @@ +// 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. + +/// This library provides API to test Flutter applications that run on real +/// devices and emulators. +/// +/// The application run in a separate process from the test itself. If you are +/// familiar with Selenium (web), Espresso (Android) or UI Automation (iOS), +/// this is Flutter's version of that. +/// +/// This is Flutter's version of Selenium WebDriver (generic web), +/// Protractor (Angular), Espresso (Android) or Earl Gray (iOS). +library flutter_driver; + +export 'src/driver.dart' show + FlutterDriver; + +export 'src/error.dart' show + DriverError, + LogLevel, + LogRecord, + flutterDriverLog; + +export 'src/find.dart' show + ObjectRef, + GetTextResult; + +export 'src/health.dart' show + Health, + HealthStatus; + +export 'src/message.dart' show + Message, + Command, + ObjectRef, + CommandWithTarget, + Result; diff --git a/packages/flutter_driver/lib/src/driver.dart b/packages/flutter_driver/lib/src/driver.dart new file mode 100644 index 0000000000..10d77637fc --- /dev/null +++ b/packages/flutter_driver/lib/src/driver.dart @@ -0,0 +1,176 @@ +// 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:vm_service_client/vm_service_client.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as rpc; + +import 'error.dart'; +import 'find.dart'; +import 'gesture.dart'; +import 'health.dart'; +import 'message.dart'; + +/// A function that connects to a Dart VM service given the [url]. +typedef Future VMServiceConnectFunction(String url); + +/// Connects to a real Dart VM service using the [VMServiceClient]. +final VMServiceConnectFunction vmServiceClientConnectFunction = + VMServiceClient.connect; + +/// The connection function used by [FlutterDriver.connect]. +/// +/// Overwrite this function if you require a different method for connecting to +/// the VM service. +VMServiceConnectFunction vmServiceConnectFunction = + vmServiceClientConnectFunction; + +/// Drives a Flutter Application running in another process. +class FlutterDriver { + + static const String _flutterExtensionMethod = 'ext.flutter_driver'; + static final Logger _log = new Logger('FlutterDriver'); + + /// Connects to a Flutter application. + /// + /// Resumes the application if it is currently paused (e.g. at a breakpoint). + /// + /// [dartVmServiceUrl] is the URL to Dart observatory (a.k.a. VM service). By + /// default it connects to `http://localhost:8181`. + static Future connect({String dartVmServiceUrl: 'http://localhost:8181'}) async { + // Connect to Dart VM servcies + _log.info('Connecting to Flutter application at $dartVmServiceUrl'); + VMServiceClient client = await vmServiceConnectFunction(dartVmServiceUrl); + VM vm = await client.getVM(); + _log.trace('Looking for the isolate'); + VMIsolate isolate = await vm.isolates.first.load(); + FlutterDriver driver = new FlutterDriver.connectedTo(client, isolate); + + // Attempts to resume the isolate, but does not crash if it fails because + // the isolate is already resumed. There could be a race with other tools, + // such as a debugger, any of which could have resumed the isolate. + Future resumeLeniently() { + _log.trace('Attempting to resume isolate'); + return isolate.resume().catchError((e) { + const vmMustBePausedCode = 101; + if (e is rpc.RpcException && e.code == vmMustBePausedCode) { + // No biggie; something else must have resumed the isolate + _log.warning( + 'Attempted to resume an already resumed isolate. This may happen ' + 'when we lose a race with another tool (usually a debugger) that ' + 'is connected to the same isolate.' + ); + } else { + // Failed to resume due to another reason. Fail hard. + throw e; + } + }); + } + + // Attempt to resume isolate if it was paused + if (isolate.pauseEvent is VMPauseStartEvent) { + _log.trace('Isolate is paused at start.'); + + // Waits for a signal from the VM service that the extension is registered + Future waitForServiceExtension() { + return isolate.onServiceExtensionAdded.firstWhere((VMServiceExtension ext) { + return ext.method == _flutterExtensionMethod; + }); + } + + // If the isolate is paused at the start, e.g. via the --start-paused + // option, then the VM service extension is not registered yet. Wait for + // it to be registered. + Future whenResumed = resumeLeniently(); + Future whenServiceExtensionReady = Future.any([ + waitForServiceExtension(), + // We will never receive the extension event if the user does not + // register it. If that happens time out. + new Future.delayed(const Duration(seconds: 10), () => 'timeout') + ]); + await whenResumed; + _log.trace('Waiting for service extension'); + dynamic signal = await whenServiceExtensionReady; + if (signal == 'timeout') { + throw new DriverError( + 'Timed out waiting for Flutter Driver extension to become available. ' + 'To enable the driver extension call registerFlutterDriverExtension ' + 'first thing in the main method of your application.' + ); + } + } else if (isolate.pauseEvent is VMPauseExitEvent || + isolate.pauseEvent is VMPauseBreakpointEvent || + isolate.pauseEvent is VMPauseExceptionEvent || + isolate.pauseEvent is VMPauseInterruptedEvent) { + // If the isolate is paused for any other reason, assume the extension is + // already there. + _log.trace('Isolate is paused mid-flight.'); + await resumeLeniently(); + } else if (isolate.pauseEvent is VMResumeEvent) { + _log.trace('Isolate is not paused. Assuming application is ready.'); + } else { + _log.warning( + 'Unknown pause event type ${isolate.pauseEvent.runtimeType}. ' + 'Assuming application is ready.' + ); + } + + // At this point the service extension must be installed. Verify it. + Health health = await driver.checkHealth(); + if (health.status != HealthStatus.ok) { + client.close(); + throw new DriverError('Flutter application health check failed.'); + } + + _log.info('Connected to Flutter application.'); + return driver; + } + + FlutterDriver.connectedTo(this._serviceClient, this._appIsolate); + + /// Client connected to the Dart VM running the Flutter application + final VMServiceClient _serviceClient; + /// The main isolate hosting the Flutter application + final VMIsolateRef _appIsolate; + + Future> _sendCommand(Command command) async { + Map json = {'kind': command.kind} + ..addAll(command.toJson()); + return _appIsolate.invokeExtension(_flutterExtensionMethod, json) + .then((Map result) => result, onError: (error, stackTrace) { + throw new DriverError( + 'Failed to fulfill ${command.runtimeType} due to remote error', + error, + stackTrace + ); + }); + } + + /// Checks the status of the Flutter Driver extension. + Future checkHealth() async { + return Health.fromJson(await _sendCommand(new GetHealth())); + } + + Future findByValueKey(dynamic key) async { + return ObjectRef.fromJson(await _sendCommand(new FindByValueKey(key))); + } + + Future tap(ObjectRef ref) async { + return await _sendCommand(new Tap(ref)).then((_) => null); + } + + Future getText(ObjectRef ref) async { + GetTextResult result = GetTextResult.fromJson(await _sendCommand(new GetText(ref))); + return result.text; + } + + /// Closes the underlying connection to the VM service. + /// + /// Returns a [Future] that fires once the connection has been closed. + // TODO(yjbanov): cleanup object references + Future close() => _serviceClient.close().then((_) { + // Don't leak vm_service_client-specific objects, if any + return null; + }); +} diff --git a/packages/flutter_driver/lib/src/error.dart b/packages/flutter_driver/lib/src/error.dart new file mode 100644 index 0000000000..36ba7484b4 --- /dev/null +++ b/packages/flutter_driver/lib/src/error.dart @@ -0,0 +1,81 @@ +// 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:io' show stderr; + +/// Standard error thrown by Flutter Driver API. +class DriverError extends Error { + DriverError(this.message, [this.originalError, this.originalStackTrace]); + + /// Human-readable error message. + final String message; + + final dynamic originalError; + final dynamic originalStackTrace; + + String toString() => 'DriverError: $message'; +} + +// Whether someone redirected the log messages somewhere. +bool _noLogSubscribers = true; + +final StreamController _logger = + new StreamController.broadcast(sync: true, onListen: () { + _noLogSubscribers = false; + }); + +void _log(LogLevel level, String loggerName, Object message) { + LogRecord record = new LogRecord._(level, loggerName, '$message'); + // If nobody expressed interest in rerouting log messages somewhere specific, + // print them to stderr. + if (_noLogSubscribers) + stderr.writeln(record); + else + _logger.add(record); +} + +/// Emits log records from Flutter Driver. +final Stream flutterDriverLog = _logger.stream; + +/// Severity of a log entry. +enum LogLevel { trace, info, warning, error, critical } + +/// A log entry. +class LogRecord { + const LogRecord._(this.level, this.loggerName, this.message); + + final LogLevel level; + final String loggerName; + final String message; + + String toString() => '[${"$level".split(".").last}] $loggerName: $message'; +} + +/// Package-private; users should use other public logging libraries. +class Logger { + Logger(this.name); + + final String name; + + void trace(Object message) { + _log(LogLevel.trace, name, message); + } + + void info(Object message) { + _log(LogLevel.info, name, message); + } + + void warning(Object message) { + _log(LogLevel.warning, name, message); + } + + void error(Object message) { + _log(LogLevel.error, name, message); + } + + void critical(Object message) { + _log(LogLevel.critical, name, message); + } +} diff --git a/packages/flutter_driver/lib/src/extension.dart b/packages/flutter_driver/lib/src/extension.dart new file mode 100644 index 0000000000..79e019bcf7 --- /dev/null +++ b/packages/flutter_driver/lib/src/extension.dart @@ -0,0 +1,137 @@ +// 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/widgets.dart'; +import 'package:flutter_test/src/instrumentation.dart'; + +import 'error.dart'; +import 'find.dart'; +import 'gesture.dart'; +import 'health.dart'; +import 'message.dart'; + +const String _extensionMethod = 'ext.flutter_driver'; + +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 params) { + return extension.call(params); + }); + _flutterDriverExtensionEnabled = true; +} + +/// Handles a command and returns a result. +typedef Future CommandHandlerCallback(Command c); + +/// Deserializes JSON map to a command object. +typedef Command CommandDeserializerCallback(Map params); + +class FlutterDriverExtension { + static final Logger _log = new Logger('FlutterDriverExtension'); + + FlutterDriverExtension() { + _commandHandlers = { + 'get_health': getHealth, + 'find_by_value_key': findByValueKey, + 'tap': tap, + 'get_text': getText, + }; + + _commandDeserializers = { + 'get_health': GetHealth.fromJson, + 'find_by_value_key': FindByValueKey.fromJson, + 'tap': Tap.fromJson, + 'get_text': GetText.fromJson, + }; + } + + final Instrumentation prober = new Instrumentation(); + + Map _commandHandlers = + {}; + + Map _commandDeserializers = + {}; + + Future call(Map params) async { + String commandKind = params['kind']; + 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'); + }); + } + + Future getHealth(GetHealth command) async => new Health(HealthStatus.ok); + + Future findByValueKey(FindByValueKey command) { + Element elem = prober.findElementByKey(new ValueKey(command.keyValue)); + ObjectRef elemRef = elem != null + ? new ObjectRef(_registerObject(elem)) + : new ObjectRef.notFound(); + return new Future.value(elemRef); + } + + Future tap(Tap command) async { + Element target = await _dereferenceOrDie(command.targetRef); + prober.tap(target); + return new TapResult(); + } + + Future 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 _objectRefs = {}; + 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 _dereferenceOrDie(String reference) { + Element object = _dereference(reference); + + if (object == null) + return new Future.error('Object reference not found ($reference).'); + + return new Future.value(object); + } +} diff --git a/packages/flutter_driver/lib/src/find.dart b/packages/flutter_driver/lib/src/find.dart new file mode 100644 index 0000000000..00e4aebe85 --- /dev/null +++ b/packages/flutter_driver/lib/src/find.dart @@ -0,0 +1,82 @@ +// 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 'error.dart'; +import 'message.dart'; + +const List _supportedKeyValueTypes = const [String, int]; + +/// Command to find an element by a value key. +class FindByValueKey extends Command { + final String kind = 'find_by_value_key'; + + FindByValueKey(dynamic keyValue) + : this.keyValue = keyValue, + this.keyValueString = '$keyValue', + this.keyValueType = '${keyValue.runtimeType}' { + if (!_supportedKeyValueTypes.contains(keyValue.runtimeType)) + _throwInvalidKeyValueType('$keyValue.runtimeType'); + } + + /// The true value of the key. + final dynamic keyValue; + + /// Stringified value of the key (we can only send strings to the VM service) + final String keyValueString; + + /// The type name of the key. + /// + /// May be one of "String", "int". The list of supported types may change. + final String keyValueType; + + Map toJson() => { + 'keyValueString': keyValueString, + 'keyValueType': keyValueType, + }; + + static FindByValueKey fromJson(Map json) { + String keyValueString = json['keyValueString']; + String keyValueType = json['keyValueType']; + switch(keyValueType) { + case 'int': + return new FindByValueKey(int.parse(keyValueString)); + case 'String': + return new FindByValueKey(keyValueString); + default: + return _throwInvalidKeyValueType(keyValueType); + } + } + + static _throwInvalidKeyValueType(String invalidType) { + throw new DriverError('Unsupported key value type $invalidType. Flutter Driver only supports ${_supportedKeyValueTypes.join(", ")}'); + } +} + +/// Command to read the text from a given element. +class GetText extends CommandWithTarget { + final String kind = 'get_text'; + + static GetText fromJson(Map json) { + return new GetText(new ObjectRef(json['targetRef'])); + } + + /// [targetRef] identifies an element that contains a piece of text. + GetText(ObjectRef targetRef) : super(targetRef); + + Map toJson() => super.toJson(); +} + +class GetTextResult extends Result { + static GetTextResult fromJson(Map json) { + return new GetTextResult(json['text']); + } + + GetTextResult(this.text); + + final String text; + + Map toJson() => { + 'text': text, + }; +} diff --git a/packages/flutter_driver/lib/src/gesture.dart b/packages/flutter_driver/lib/src/gesture.dart new file mode 100644 index 0000000000..2e44a3bd63 --- /dev/null +++ b/packages/flutter_driver/lib/src/gesture.dart @@ -0,0 +1,25 @@ +// 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 'message.dart'; + +class Tap extends CommandWithTarget { + final String kind = 'tap'; + + Tap(ObjectRef targetRef) : super(targetRef); + + static Tap fromJson(Map json) { + return new Tap(new ObjectRef(json['targetRef'])); + } + + Map toJson() => super.toJson(); +} + +class TapResult extends Result { + static TapResult fromJson(Map json) { + return new TapResult(); + } + + Map toJson() => {}; +} diff --git a/packages/flutter_driver/lib/src/health.dart b/packages/flutter_driver/lib/src/health.dart new file mode 100644 index 0000000000..5ce9495a0e --- /dev/null +++ b/packages/flutter_driver/lib/src/health.dart @@ -0,0 +1,54 @@ +// 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 'message.dart'; + +/// Requests an application health check. +class GetHealth implements Command { + final String kind = 'get_health'; + + static fromJson(Map json) => new GetHealth(); + + Map toJson() => const {}; +} + +/// Application health status. +enum HealthStatus { + /// Application is known to be in a good shape and should be able to respond. + ok, + + /// Application is not known to be in a good shape and may be unresponsive. + bad, +} + +/// Application health status. +class Health extends Result { + Health(this.status) { + assert(status != null); + } + + static Health fromJson(Map json) { + return new Health(_statusFromId(json['status'])); + } + + /// Health status + final HealthStatus status; + + Map toJson() => { + 'status': _getStatusId(status) + }; +} + +String _getStatusId(HealthStatus status) => status.toString().split('.').last; + +final Map _idToStatus = new Map.fromIterable( + HealthStatus.values, + key: _getStatusId +); + +HealthStatus _statusFromId(String id) { + return _idToStatus.containsKey(id) + ? _idToStatus[id] + : throw new ArgumentError.value(id, 'id', 'unknown'); +} diff --git a/packages/flutter_driver/lib/src/message.dart b/packages/flutter_driver/lib/src/message.dart new file mode 100644 index 0000000000..9497ef8eff --- /dev/null +++ b/packages/flutter_driver/lib/src/message.dart @@ -0,0 +1,75 @@ +// 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 'error.dart'; + +/// A piece of data travelling between Flutter Driver and a Flutter application. +abstract class Message { + /// Serializes this message to a JSON map. + Map toJson(); +} + +/// A message that travels from the Flutter Driver to a Flutter application to +/// instruct the application to perform a task. +abstract class Command extends Message { + /// Identifies the type of the command object and of the handler. + String get kind; +} + +/// A message sent from a Flutter application back to the Flutter Driver in +/// response to a command. +abstract class Result extends Message { } + +/// A serializable reference to an object that lives in the application isolate. +class ObjectRef extends Result { + ObjectRef(this.objectReferenceKey); + + ObjectRef.notFound() : this(null); + + static ObjectRef fromJson(Map json) { + return json['objectReferenceKey'] != null + ? new ObjectRef(json['objectReferenceKey']) + : null; + } + + /// Identifier used to dereference an object. + /// + /// This value is generated by the application-side isolate. Flutter driver + /// tests should not generate these keys. + final String objectReferenceKey; + + Map toJson() => { + 'objectReferenceKey': objectReferenceKey, + }; +} + +/// A command aimed at an object represented by [targetRef]. +/// +/// Implementations must provide a concrete [kind]. If additional data is +/// required beyond the [targetRef] the implementation may override [toJson] +/// and add more keys to the returned map. +abstract class CommandWithTarget extends Command { + CommandWithTarget(ObjectRef ref) : this.targetRef = ref?.objectReferenceKey { + if (ref == null) + throw new DriverError('${this.runtimeType} target cannot be null'); + + if (ref.objectReferenceKey == null) + throw new DriverError('${this.runtimeType} target reference cannot be null'); + } + + /// Refers to the object targeted by this command. + final String targetRef; + + /// This method is meant to be overridden if data in addition to [targetRef] + /// is serialized to JSON. + /// + /// Example: + /// + /// Map toJson() => super.toJson()..addAll({ + /// 'foo': this.foo, + /// }); + Map toJson() => { + 'targetRef': targetRef, + }; +} diff --git a/packages/flutter_driver/pubspec.yaml b/packages/flutter_driver/pubspec.yaml new file mode 100644 index 0000000000..2918a68002 --- /dev/null +++ b/packages/flutter_driver/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_driver +version: 0.0.1 +description: Integration and performance test API for Flutter applications +homepage: http://flutter.io +author: Flutter Authors + +environment: + sdk: '>=1.12.0 <2.0.0' + +dependencies: + vm_service_client: + git: + url: git://github.com/yjbanov/vm_service_client.git + ref: 54085d1 + json_rpc_2: any + logging: '>=0.11.0 <1.0.0' + flutter: + path: '../flutter' + flutter_test: + path: '../flutter_test' + +dev_dependencies: + test: '>=0.12.6 <1.0.0' + mockito: ^0.10.1 diff --git a/packages/flutter_driver/test/flutter_driver_test.dart b/packages/flutter_driver/test/flutter_driver_test.dart new file mode 100644 index 0000000000..3dee762499 --- /dev/null +++ b/packages/flutter_driver/test/flutter_driver_test.dart @@ -0,0 +1,208 @@ +// 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:test/test.dart'; +import 'package:flutter_driver/src/driver.dart'; +import 'package:flutter_driver/src/error.dart'; +import 'package:flutter_driver/src/health.dart'; +import 'package:flutter_driver/src/message.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as rpc; +import 'package:mockito/mockito.dart'; +import 'package:vm_service_client/vm_service_client.dart'; + +main() { + group('FlutterDriver.connect', () { + List log; + StreamSubscription logSub; + MockVMServiceClient mockClient; + MockVM mockVM; + MockIsolate mockIsolate; + + expectLogContains(String message) { + expect(log.map((r) => '$r'), anyElement(contains(message))); + } + + setUp(() { + log = []; + logSub = flutterDriverLog.listen(log.add); + mockClient = new MockVMServiceClient(); + mockVM = new MockVM(); + mockIsolate = new MockIsolate(); + when(mockClient.getVM()).thenReturn(mockVM); + when(mockVM.isolates).thenReturn([mockIsolate]); + when(mockIsolate.load()).thenReturn(mockIsolate); + when(mockIsolate.invokeExtension(any, any)) + .thenReturn(new Future.value({'status': 'ok'})); + vmServiceConnectFunction = (_) => new Future.value(mockClient); + }); + + tearDown(() async { + await logSub.cancel(); + vmServiceConnectFunction = vmServiceClientConnectFunction; + }); + + test('connects to isolate paused at start', () async { + when(mockIsolate.pauseEvent).thenReturn(new MockVMPauseStartEvent()); + when(mockIsolate.resume()).thenReturn(new Future.value()); + MockVMServiceExtension mockExtension = new MockVMServiceExtension(); + when(mockExtension.method).thenReturn('ext.flutter_driver'); + when(mockIsolate.onServiceExtensionAdded) + .thenReturn(new Stream.fromIterable([mockExtension])); + + FlutterDriver driver = await FlutterDriver.connect(); + expect(driver, isNotNull); + expectLogContains('Isolate is paused at start'); + }); + + test('connects to isolate paused mid-flight', () async { + when(mockIsolate.pauseEvent).thenReturn(new MockVMPauseBreakpointEvent()); + when(mockIsolate.resume()).thenReturn(new Future.value()); + + FlutterDriver driver = await FlutterDriver.connect(); + expect(driver, isNotNull); + expectLogContains('Isolate is paused mid-flight'); + }); + + // This test simulates a situation when we believe that the isolate is + // currently paused, but something else (e.g. a debugger) resumes it before + // we do. There's no need to fail as we should be able to drive the app + // just fine. + test('connects despite losing the race to resume isolate', () async { + when(mockIsolate.pauseEvent).thenReturn(new MockVMPauseBreakpointEvent()); + when(mockIsolate.resume()).thenAnswer((_) { + // This needs to be wrapped in a closure to not be considered uncaught + // by package:test + return new Future.error(new rpc.RpcException(101, '')); + }); + + FlutterDriver driver = await FlutterDriver.connect(); + expect(driver, isNotNull); + expectLogContains('Attempted to resume an already resumed isolate'); + }); + + test('connects to unpaused isolate', () async { + when(mockIsolate.pauseEvent).thenReturn(new MockVMResumeEvent()); + FlutterDriver driver = await FlutterDriver.connect(); + expect(driver, isNotNull); + expectLogContains('Isolate is not paused. Assuming application is ready.'); + }); + }); + + group('FlutterDriver', () { + MockVMServiceClient mockClient; + MockIsolate mockIsolate; + FlutterDriver driver; + + setUp(() { + mockClient = new MockVMServiceClient(); + mockIsolate = new MockIsolate(); + driver = new FlutterDriver.connectedTo(mockClient, mockIsolate); + }); + + test('checks the health of the driver extension', () async { + when(mockIsolate.invokeExtension(any, any)).thenReturn(new Future.value({ + 'status': 'ok', + })); + Health result = await driver.checkHealth(); + expect(result.status, HealthStatus.ok); + }); + + test('closes connection', () async { + when(mockClient.close()).thenReturn(new Future.value()); + await driver.close(); + }); + + group('findByValueKey', () { + test('restricts value types', () async { + expect(driver.findByValueKey(null), + throwsA(new isInstanceOf())); + }); + + test('finds by ValueKey', () async { + when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { + expect(i.positionalArguments[1], { + 'kind': 'find_by_value_key', + 'keyValueString': 'foo', + 'keyValueType': 'String', + }); + return new Future.value({ + 'objectReferenceKey': '123', + }); + }); + ObjectRef result = await driver.findByValueKey('foo'); + expect(result, isNotNull); + expect(result.objectReferenceKey, '123'); + }); + }); + + group('tap', () { + test('requires a target reference', () async { + expect(driver.tap(null), throwsA(new isInstanceOf())); + }); + + test('requires a valid target reference', () async { + expect(driver.tap(new ObjectRef.notFound()), + throwsA(new isInstanceOf())); + }); + + test('sends the tap command', () async { + when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { + expect(i.positionalArguments[1], { + 'kind': 'tap', + 'targetRef': '123' + }); + return new Future.value(); + }); + await driver.tap(new ObjectRef('123')); + }); + }); + + group('getText', () { + test('requires a target reference', () async { + expect(driver.getText(null), throwsA(new isInstanceOf())); + }); + + test('requires a valid target reference', () async { + expect(driver.getText(new ObjectRef.notFound()), + throwsA(new isInstanceOf())); + }); + + test('sends the getText command', () async { + when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { + expect(i.positionalArguments[1], { + 'kind': 'get_text', + 'targetRef': '123' + }); + return new Future.value({ + 'text': 'hello' + }); + }); + String result = await driver.getText(new ObjectRef('123')); + expect(result, 'hello'); + }); + }); + }); +} + +@proxy +class MockVMServiceClient extends Mock implements VMServiceClient { } + +@proxy +class MockVM extends Mock implements VM { } + +@proxy +class MockIsolate extends Mock implements VMRunnableIsolate { } + +@proxy +class MockVMPauseStartEvent extends Mock implements VMPauseStartEvent { } + +@proxy +class MockVMPauseBreakpointEvent extends Mock implements VMPauseBreakpointEvent { } + +@proxy +class MockVMResumeEvent extends Mock implements VMResumeEvent { } + +@proxy +class MockVMServiceExtension extends Mock implements VMServiceExtension { } diff --git a/travis/test.sh b/travis/test.sh index c3f6099939..f92c880988 100755 --- a/travis/test.sh +++ b/travis/test.sh @@ -15,5 +15,6 @@ flutter analyze --flutter-repo --no-current-directory --no-current-package --con (cd packages/newton; pub run test -j1) # (cd packages/playfair; ) # No tests to run. # (cd packages/updater; ) # No tests to run. +(cd packages/flutter_driver; pub run test -j1) (cd examples/stocks; flutter test)