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)
diff --git a/packages/flutter_driver/lib/driver_extension.dart b/packages/flutter_driver/lib/driver_extension.dart
new file mode 100644
index 0000000..15d3eae
--- /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 0000000..de554be
--- /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 0000000..10d7763
--- /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<VMServiceClient> 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<FlutterDriver> 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(<Future>[
+        waitForServiceExtension(),
+        // We will never receive the extension event if the user does not
+        // register it. If that happens time out.
+        new Future<String>.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<Map<String, dynamic>> _sendCommand(Command command) async {
+    Map<String, dynamic> json = <String, dynamic>{'kind': command.kind}
+      ..addAll(command.toJson());
+    return _appIsolate.invokeExtension(_flutterExtensionMethod, json)
+      .then((Map<String, dynamic> 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<Health> checkHealth() async {
+    return Health.fromJson(await _sendCommand(new GetHealth()));
+  }
+
+  Future<ObjectRef> findByValueKey(dynamic key) async {
+    return ObjectRef.fromJson(await _sendCommand(new FindByValueKey(key)));
+  }
+
+  Future<Null> tap(ObjectRef ref) async {
+    return await _sendCommand(new Tap(ref)).then((_) => null);
+  }
+
+  Future<String> 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 0000000..36ba748
--- /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<LogRecord> _logger =
+    new StreamController<LogRecord>.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<LogRecord> 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 0000000..79e019b
--- /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<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_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<String, CommandHandlerCallback> _commandHandlers =
+      <String, CommandHandlerCallback>{};
+
+  Map<String, CommandDeserializerCallback> _commandDeserializers =
+      <String, CommandDeserializerCallback>{};
+
+  Future<ServiceExtensionResponse> call(Map<String, String> 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<Health> getHealth(GetHealth command) async => new Health(HealthStatus.ok);
+
+  Future<ObjectRef> findByValueKey(FindByValueKey command) {
+    Element elem = prober.findElementByKey(new ValueKey<dynamic>(command.keyValue));
+    ObjectRef elemRef = elem != null
+      ? new ObjectRef(_registerObject(elem))
+      : new ObjectRef.notFound();
+    return new Future.value(elemRef);
+  }
+
+  Future<TapResult> tap(Tap command) async {
+    Element target = await _dereferenceOrDie(command.targetRef);
+    prober.tap(target);
+    return new TapResult();
+  }
+
+  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);
+  }
+}
diff --git a/packages/flutter_driver/lib/src/find.dart b/packages/flutter_driver/lib/src/find.dart
new file mode 100644
index 0000000..00e4aeb
--- /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<Type> _supportedKeyValueTypes = const <Type>[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<String, dynamic> toJson() => {
+    'keyValueString': keyValueString,
+    'keyValueType': keyValueType,
+  };
+
+  static FindByValueKey fromJson(Map<String, dynamic> 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<String, dynamic> json) {
+    return new GetText(new ObjectRef(json['targetRef']));
+  }
+
+  /// [targetRef] identifies an element that contains a piece of text.
+  GetText(ObjectRef targetRef) : super(targetRef);
+
+  Map<String, dynamic> toJson() => super.toJson();
+}
+
+class GetTextResult extends Result {
+  static GetTextResult fromJson(Map<String, dynamic> json) {
+    return new GetTextResult(json['text']);
+  }
+
+  GetTextResult(this.text);
+
+  final String text;
+
+  Map<String, dynamic> 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 0000000..2e44a3b
--- /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<String, dynamic> json) {
+    return new Tap(new ObjectRef(json['targetRef']));
+  }
+
+  Map<String, dynamic> toJson() => super.toJson();
+}
+
+class TapResult extends Result {
+  static TapResult fromJson(Map<String, dynamic> json) {
+    return new TapResult();
+  }
+
+  Map<String, dynamic> toJson() => {};
+}
diff --git a/packages/flutter_driver/lib/src/health.dart b/packages/flutter_driver/lib/src/health.dart
new file mode 100644
index 0000000..5ce9495
--- /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<String, dynamic> json) => new GetHealth();
+
+  Map<String, dynamic> 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<String, dynamic> json) {
+    return new Health(_statusFromId(json['status']));
+  }
+
+  /// Health status
+  final HealthStatus status;
+
+  Map<String, dynamic> toJson() => {
+    'status': _getStatusId(status)
+  };
+}
+
+String _getStatusId(HealthStatus status) => status.toString().split('.').last;
+
+final Map<String, HealthStatus> _idToStatus = new Map<String, HealthStatus>.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 0000000..9497ef8
--- /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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => super.toJson()..addAll({
+  ///       'foo': this.foo,
+  ///     });
+  Map<String, dynamic> toJson() => {
+    'targetRef': targetRef,
+  };
+}
diff --git a/packages/flutter_driver/pubspec.yaml b/packages/flutter_driver/pubspec.yaml
new file mode 100644
index 0000000..2918a68
--- /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 <flutter-dev@googlegroups.com>
+
+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 0000000..3dee762
--- /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<LogRecord> log;
+    StreamSubscription logSub;
+    MockVMServiceClient mockClient;
+    MockVM mockVM;
+    MockIsolate mockIsolate;
+
+    expectLogContains(String message) {
+      expect(log.map((r) => '$r'), anyElement(contains(message)));
+    }
+
+    setUp(() {
+      log = <LogRecord>[];
+      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<DriverError>()));
+      });
+
+      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<DriverError>()));
+      });
+
+      test('requires a valid target reference', () async {
+        expect(driver.tap(new ObjectRef.notFound()),
+          throwsA(new isInstanceOf<DriverError>()));
+      });
+
+      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<DriverError>()));
+      });
+
+      test('requires a valid target reference', () async {
+        expect(driver.getText(new ObjectRef.notFound()),
+          throwsA(new isInstanceOf<DriverError>()));
+      });
+
+      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 c3f6099..f92c880 100755
--- a/travis/test.sh
+++ b/travis/test.sh
@@ -15,5 +15,6 @@
 (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)