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)
This commit is contained in:
parent
d93a87ee12
commit
b0e4559459
27
packages/flutter_driver/lib/driver_extension.dart
Normal file
27
packages/flutter_driver/lib/driver_extension.dart
Normal file
@ -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;
|
38
packages/flutter_driver/lib/flutter_driver.dart
Normal file
38
packages/flutter_driver/lib/flutter_driver.dart
Normal file
@ -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;
|
176
packages/flutter_driver/lib/src/driver.dart
Normal file
176
packages/flutter_driver/lib/src/driver.dart
Normal file
@ -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;
|
||||||
|
});
|
||||||
|
}
|
81
packages/flutter_driver/lib/src/error.dart
Normal file
81
packages/flutter_driver/lib/src/error.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
137
packages/flutter_driver/lib/src/extension.dart
Normal file
137
packages/flutter_driver/lib/src/extension.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
82
packages/flutter_driver/lib/src/find.dart
Normal file
82
packages/flutter_driver/lib/src/find.dart
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
25
packages/flutter_driver/lib/src/gesture.dart
Normal file
25
packages/flutter_driver/lib/src/gesture.dart
Normal file
@ -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() => {};
|
||||||
|
}
|
54
packages/flutter_driver/lib/src/health.dart
Normal file
54
packages/flutter_driver/lib/src/health.dart
Normal file
@ -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');
|
||||||
|
}
|
75
packages/flutter_driver/lib/src/message.dart
Normal file
75
packages/flutter_driver/lib/src/message.dart
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
24
packages/flutter_driver/pubspec.yaml
Normal file
24
packages/flutter_driver/pubspec.yaml
Normal file
@ -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
|
208
packages/flutter_driver/test/flutter_driver_test.dart
Normal file
208
packages/flutter_driver/test/flutter_driver_test.dart
Normal file
@ -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 { }
|
@ -15,5 +15,6 @@ flutter analyze --flutter-repo --no-current-directory --no-current-package --con
|
|||||||
(cd packages/newton; pub run test -j1)
|
(cd packages/newton; pub run test -j1)
|
||||||
# (cd packages/playfair; ) # No tests to run.
|
# (cd packages/playfair; ) # No tests to run.
|
||||||
# (cd packages/updater; ) # No tests to run.
|
# (cd packages/updater; ) # No tests to run.
|
||||||
|
(cd packages/flutter_driver; pub run test -j1)
|
||||||
|
|
||||||
(cd examples/stocks; flutter test)
|
(cd examples/stocks; flutter test)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user