Commit 6d35481c authored by Yegor Jbanov's avatar Yegor Jbanov

add smoke driver test; find by tooltip; retry predicate

parent 6a1f47a5
...@@ -151,8 +151,19 @@ class FlutterDriver { ...@@ -151,8 +151,19 @@ class FlutterDriver {
return Health.fromJson(await _sendCommand(new GetHealth())); return Health.fromJson(await _sendCommand(new GetHealth()));
} }
/// Finds the UI element with the given [key].
Future<ObjectRef> findByValueKey(dynamic key) async { Future<ObjectRef> findByValueKey(dynamic key) async {
return ObjectRef.fromJson(await _sendCommand(new FindByValueKey(key))); return ObjectRef.fromJson(await _sendCommand(new Find(new ByValueKey(key))));
}
/// Finds the UI element for the tooltip with the given [message].
Future<ObjectRef> findByTooltipMessage(String message) async {
return ObjectRef.fromJson(await _sendCommand(new Find(new ByTooltipMessage(message))));
}
/// Finds the text element with the given [text].
Future<ObjectRef> findByText(String text) async {
return ObjectRef.fromJson(await _sendCommand(new Find(new ByText(text))));
} }
Future<Null> tap(ObjectRef ref) async { Future<Null> tap(ObjectRef ref) async {
......
...@@ -6,7 +6,8 @@ ...@@ -6,7 +6,8 @@
/// ///
/// In Dart enum names are prefixed with enum class name. For example, for /// In Dart enum names are prefixed with enum class name. For example, for
/// `enum Vote { yea, nay }`, `Vote.yea.toString()` produces `"Vote.yea"` /// `enum Vote { yea, nay }`, `Vote.yea.toString()` produces `"Vote.yea"`
/// rather than just `"yea"` - the simple name. /// rather than just `"yea"` - the simple name. This class provides methods for
/// getting and looking up by simple names.
/// ///
/// Example: /// Example:
/// ///
......
...@@ -15,7 +15,13 @@ class DriverError extends Error { ...@@ -15,7 +15,13 @@ class DriverError extends Error {
final dynamic originalError; final dynamic originalError;
final dynamic originalStackTrace; final dynamic originalStackTrace;
String toString() => 'DriverError: $message'; String toString() {
return '''DriverError: $message
Original error: $originalError
Original stack trace:
$originalStackTrace
''';
}
} }
// Whether someone redirected the log messages somewhere. // Whether someone redirected the log messages somewhere.
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/src/instrumentation.dart'; import 'package:flutter_test/src/instrumentation.dart';
...@@ -50,14 +51,14 @@ class FlutterDriverExtension { ...@@ -50,14 +51,14 @@ class FlutterDriverExtension {
FlutterDriverExtension() { FlutterDriverExtension() {
_commandHandlers = { _commandHandlers = {
'get_health': getHealth, 'get_health': getHealth,
'find_by_value_key': findByValueKey, 'find': find,
'tap': tap, 'tap': tap,
'get_text': getText, 'get_text': getText,
}; };
_commandDeserializers = { _commandDeserializers = {
'get_health': GetHealth.fromJson, 'get_health': GetHealth.fromJson,
'find_by_value_key': FindByValueKey.fromJson, 'find': Find.fromJson,
'tap': Tap.fromJson, 'tap': Tap.fromJson,
'get_text': GetText.fromJson, 'get_text': GetText.fromJson,
}; };
...@@ -72,41 +73,95 @@ class FlutterDriverExtension { ...@@ -72,41 +73,95 @@ class FlutterDriverExtension {
<String, CommandDeserializerCallback>{}; <String, CommandDeserializerCallback>{};
Future<ServiceExtensionResponse> call(Map<String, String> params) async { Future<ServiceExtensionResponse> call(Map<String, String> params) async {
String commandKind = params['kind']; try {
CommandHandlerCallback commandHandler = _commandHandlers[commandKind]; String commandKind = params['kind'];
CommandDeserializerCallback commandDeserializer = CommandHandlerCallback commandHandler = _commandHandlers[commandKind];
_commandDeserializers[commandKind]; CommandDeserializerCallback commandDeserializer =
_commandDeserializers[commandKind];
if (commandHandler == null || commandDeserializer == null) {
return new ServiceExtensionResponse.error( if (commandHandler == null || commandDeserializer == null) {
ServiceExtensionResponse.kInvalidParams, return new ServiceExtensionResponse.error(
'Extension $_extensionMethod does not support command $commandKind' ServiceExtensionResponse.kInvalidParams,
); 'Extension $_extensionMethod does not support command $commandKind'
);
}
Command command = commandDeserializer(params);
return commandHandler(command).then((Result result) {
return new ServiceExtensionResponse.result(JSON.encode(result.toJson()));
}, onError: (e, s) {
_log.warning('$e:\n$s');
return new ServiceExtensionResponse.error(
ServiceExtensionResponse.kExtensionError, '$e');
});
} catch(error, stackTrace) {
_log.warning('Uncaught extension error: $error\n$stackTrace');
} }
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<Health> getHealth(GetHealth command) async => new Health(HealthStatus.ok);
Future<ObjectRef> findByValueKey(FindByValueKey command) async { Future<ObjectRef> find(Find command) async {
Element elem = await retry(() { SearchSpecification searchSpec = command.searchSpec;
return prober.findElementByKey(new ValueKey<dynamic>(command.keyValue)); switch(searchSpec.runtimeType) {
}, _kDefaultTimeout, _kDefaultPauseBetweenRetries); case ByValueKey: return findByValueKey(searchSpec);
case ByTooltipMessage: return findByTooltipMessage(searchSpec);
case ByText: return findByText(searchSpec);
}
throw new DriverError('Unsupported search specification type ${searchSpec.runtimeType}');
}
ObjectRef elemRef = elem != null /// Runs object [locator] repeatedly until it returns a non-`null` value.
? new ObjectRef(_registerObject(elem)) ///
/// [descriptionGetter] describes the object to be waited for. It is used in
/// the warning printed should timeout happen.
Future<ObjectRef> _waitForObject(String descriptionGetter(), Object locator()) async {
Object object = await retry(locator, _kDefaultTimeout, _kDefaultPauseBetweenRetries, predicate: (object) {
return object != null;
}).catchError((dynamic error, stackTrace) {
_log.warning('Timed out waiting for ${descriptionGetter()}');
return null;
});
ObjectRef elemRef = object != null
? new ObjectRef(_registerObject(object))
: new ObjectRef.notFound(); : new ObjectRef.notFound();
return new Future.value(elemRef); return new Future.value(elemRef);
} }
Future<ObjectRef> findByValueKey(ByValueKey byKey) async {
return _waitForObject(
() => 'element with key "${byKey.keyValue}" of type ${byKey.keyValueType}',
() {
return prober.findElementByKey(new ValueKey<dynamic>(byKey.keyValue));
}
);
}
Future<ObjectRef> findByTooltipMessage(ByTooltipMessage byTooltipMessage) async {
return _waitForObject(
() => 'tooltip with message "${byTooltipMessage.text}" on it',
() {
return prober.findElement((Element element) {
Widget widget = element.widget;
if (widget is Tooltip)
return widget.message == byTooltipMessage.text;
return false;
});
}
);
}
Future<ObjectRef> findByText(ByText byText) async {
return await _waitForObject(
() => 'text "${byText.text}"',
() {
return prober.findText(byText.text);
});
}
Future<TapResult> tap(Tap command) async { Future<TapResult> tap(Tap command) async {
Element target = await _dereferenceOrDie(command.targetRef); Element target = await _dereferenceOrDie(command.targetRef);
prober.tap(target); prober.tap(target);
......
...@@ -7,11 +7,84 @@ import 'message.dart'; ...@@ -7,11 +7,84 @@ import 'message.dart';
const List<Type> _supportedKeyValueTypes = const <Type>[String, int]; const List<Type> _supportedKeyValueTypes = const <Type>[String, int];
/// Command to find an element by a value key. /// Command to find an element.
class FindByValueKey extends Command { class Find extends Command {
final String kind = 'find_by_value_key'; final String kind = 'find';
FindByValueKey(dynamic keyValue) Find(this.searchSpec);
final SearchSpecification searchSpec;
Map<String, dynamic> toJson() => searchSpec.toJson();
static Find fromJson(Map<String, dynamic> json) {
return new Find(SearchSpecification.fromJson(json));
}
static _throwInvalidKeyValueType(String invalidType) {
throw new DriverError('Unsupported key value type $invalidType. Flutter Driver only supports ${_supportedKeyValueTypes.join(", ")}');
}
}
/// Describes how to the driver should search for elements.
abstract class SearchSpecification extends Message {
String get searchSpecType;
static SearchSpecification fromJson(Map<String, dynamic> json) {
String searchSpecType = json['searchSpecType'];
switch(searchSpecType) {
case 'ByValueKey': return ByValueKey.fromJson(json);
case 'ByTooltipMessage': return ByTooltipMessage.fromJson(json);
case 'ByText': return ByText.fromJson(json);
}
throw new DriverError('Unsupported search specification type $searchSpecType');
}
Map<String, dynamic> toJson() => {
'searchSpecType': searchSpecType,
};
}
/// Tells [Find] to search by tooltip text.
class ByTooltipMessage extends SearchSpecification {
final String searchSpecType = 'ByTooltipMessage';
ByTooltipMessage(this.text);
/// Tooltip message text.
final String text;
Map<String, dynamic> toJson() => super.toJson()..addAll({
'text': text,
});
static ByTooltipMessage fromJson(Map<String, dynamic> json) {
return new ByTooltipMessage(json['text']);
}
}
/// Tells [Find] to search for `Text` widget by text.
class ByText extends SearchSpecification {
final String searchSpecType = 'ByText';
ByText(this.text);
final String text;
Map<String, dynamic> toJson() => super.toJson()..addAll({
'text': text,
});
static ByText fromJson(Map<String, dynamic> json) {
return new ByText(json['text']);
}
}
/// Tells [Find] to search by `ValueKey`.
class ByValueKey extends SearchSpecification {
final String searchSpecType = 'ByValueKey';
ByValueKey(dynamic keyValue)
: this.keyValue = keyValue, : this.keyValue = keyValue,
this.keyValueString = '$keyValue', this.keyValueString = '$keyValue',
this.keyValueType = '${keyValue.runtimeType}' { this.keyValueType = '${keyValue.runtimeType}' {
...@@ -30,19 +103,19 @@ class FindByValueKey extends Command { ...@@ -30,19 +103,19 @@ class FindByValueKey extends Command {
/// May be one of "String", "int". The list of supported types may change. /// May be one of "String", "int". The list of supported types may change.
final String keyValueType; final String keyValueType;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => super.toJson()..addAll({
'keyValueString': keyValueString, 'keyValueString': keyValueString,
'keyValueType': keyValueType, 'keyValueType': keyValueType,
}; });
static FindByValueKey fromJson(Map<String, dynamic> json) { static ByValueKey fromJson(Map<String, dynamic> json) {
String keyValueString = json['keyValueString']; String keyValueString = json['keyValueString'];
String keyValueType = json['keyValueType']; String keyValueType = json['keyValueType'];
switch(keyValueType) { switch(keyValueType) {
case 'int': case 'int':
return new FindByValueKey(int.parse(keyValueString)); return new ByValueKey(int.parse(keyValueString));
case 'String': case 'String':
return new FindByValueKey(keyValueString); return new ByValueKey(keyValueString);
default: default:
return _throwInvalidKeyValueType(keyValueType); return _throwInvalidKeyValueType(keyValueType);
} }
......
...@@ -8,13 +8,17 @@ import 'dart:async'; ...@@ -8,13 +8,17 @@ import 'dart:async';
/// that evaluates to the result. /// that evaluates to the result.
typedef dynamic Action(); typedef dynamic Action();
/// Determines if [value] is acceptable. For good style an implementation should
/// be idempotent.
typedef bool Predicate(dynamic value);
/// Performs [action] repeatedly until it either succeeds or [timeout] limit is /// Performs [action] repeatedly until it either succeeds or [timeout] limit is
/// reached. /// reached.
/// ///
/// When the retry time out, the last seen error and stack trace are returned in /// When the retry time out, the last seen error and stack trace are returned in
/// an error [Future]. /// an error [Future].
Future<dynamic> retry(Action action, Duration timeout, Future<dynamic> retry(Action action, Duration timeout,
Duration pauseBetweenRetries) async { Duration pauseBetweenRetries, { Predicate predicate }) async {
assert(action != null); assert(action != null);
assert(timeout != null); assert(timeout != null);
assert(pauseBetweenRetries != null); assert(pauseBetweenRetries != null);
...@@ -28,20 +32,25 @@ Future<dynamic> retry(Action action, Duration timeout, ...@@ -28,20 +32,25 @@ Future<dynamic> retry(Action action, Duration timeout,
while(!success && sw.elapsed < timeout) { while(!success && sw.elapsed < timeout) {
try { try {
result = await action(); result = await action();
success = true; if (predicate == null || predicate(result))
success = true;
lastError = null;
lastStackTrace = null;
} catch(error, stackTrace) { } catch(error, stackTrace) {
lastError = error; lastError = error;
lastStackTrace = stackTrace; lastStackTrace = stackTrace;
if (sw.elapsed < timeout) {
await new Future<Null>.delayed(pauseBetweenRetries);
}
} }
if (!success && sw.elapsed < timeout)
await new Future<Null>.delayed(pauseBetweenRetries);
} }
if (success) if (success)
return result; return result;
else else if (lastError != null)
return new Future.error(lastError, lastStackTrace); return new Future.error(lastError, lastStackTrace);
else
return new Future.error('Retry timed out');
} }
/// A function that produces a [Stopwatch]. /// A function that produces a [Stopwatch].
......
...@@ -122,9 +122,10 @@ main() { ...@@ -122,9 +122,10 @@ main() {
test('finds by ValueKey', () async { test('finds by ValueKey', () async {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], { expect(i.positionalArguments[1], {
'kind': 'find_by_value_key', 'kind': 'find',
'searchSpecType': 'ByValueKey',
'keyValueString': 'foo', 'keyValueString': 'foo',
'keyValueType': 'String', 'keyValueType': 'String'
}); });
return new Future.value({ return new Future.value({
'objectReferenceKey': '123', 'objectReferenceKey': '123',
......
...@@ -51,6 +51,26 @@ main() { ...@@ -51,6 +51,26 @@ main() {
}); });
}); });
test('obeys predicates', () {
fakeAsync.run((_) {
int retryCount = 0;
expect(
// The predicate requires that the returned value is 2, so we expect
// that `retry` keeps trying until the counter reaches 2.
retry(
() async => retryCount++,
new Duration(milliseconds: 30),
new Duration(milliseconds: 10),
predicate: (value) => value == 2
),
completion(2)
);
fakeAsync.elapse(new Duration(milliseconds: 50));
});
});
test('times out returning last error', () async { test('times out returning last error', () async {
fakeAsync.run((_) { fakeAsync.run((_) {
bool timedOut = false; bool timedOut = false;
......
...@@ -55,6 +55,20 @@ bool _addPackage(String directoryPath, List<String> dartFiles, Set<String> pubSp ...@@ -55,6 +55,20 @@ bool _addPackage(String directoryPath, List<String> dartFiles, Set<String> pubSp
} }
} }
Directory testDriverDirectory = new Directory(path.join(directoryPath, 'test_driver'));
if (testDriverDirectory.existsSync()) {
for (FileSystemEntity entry in testDriverDirectory.listSync()) {
if (entry is Directory) {
for (FileSystemEntity subentry in entry.listSync()) {
if (isDartTestFile(subentry))
dartFiles.add(subentry.path);
}
} else if (isDartTestFile(entry)) {
dartFiles.add(entry.path);
}
}
}
Directory benchmarkDirectory = new Directory(path.join(directoryPath, 'benchmark')); Directory benchmarkDirectory = new Directory(path.join(directoryPath, 'benchmark'));
if (benchmarkDirectory.existsSync()) { if (benchmarkDirectory.existsSync()) {
for (FileSystemEntity entry in benchmarkDirectory.listSync()) { for (FileSystemEntity entry in benchmarkDirectory.listSync()) {
...@@ -76,6 +90,18 @@ bool _addPackage(String directoryPath, List<String> dartFiles, Set<String> pubSp ...@@ -76,6 +90,18 @@ bool _addPackage(String directoryPath, List<String> dartFiles, Set<String> pubSp
return false; return false;
} }
/// Adds all packages in [subPath], assuming a flat directory structure, i.e.
/// each direct child of [subPath] is a plain Dart package.
void _addFlatPackageList(String subPath, List<String> dartFiles, Set<String> pubSpecDirectories) {
Directory subdirectory = new Directory(path.join(ArtifactStore.flutterRoot, subPath));
if (subdirectory.existsSync()) {
for (FileSystemEntity entry in subdirectory.listSync()) {
if (entry is Directory)
_addPackage(entry.path, dartFiles, pubSpecDirectories);
}
}
}
class FileChanged { } class FileChanged { }
class AnalyzeCommand extends FlutterCommand { class AnalyzeCommand extends FlutterCommand {
...@@ -146,23 +172,10 @@ class AnalyzeCommand extends FlutterCommand { ...@@ -146,23 +172,10 @@ class AnalyzeCommand extends FlutterCommand {
//dev/manual_tests/*/ as package //dev/manual_tests/*/ as package
//dev/manual_tests/*/ as files //dev/manual_tests/*/ as files
Directory subdirectory; _addFlatPackageList('packages', dartFiles, pubSpecDirectories);
_addFlatPackageList('examples', dartFiles, pubSpecDirectories);
subdirectory = new Directory(path.join(ArtifactStore.flutterRoot, 'packages'));
if (subdirectory.existsSync()) {
for (FileSystemEntity entry in subdirectory.listSync()) {
if (entry is Directory)
_addPackage(entry.path, dartFiles, pubSpecDirectories);
}
}
subdirectory = new Directory(path.join(ArtifactStore.flutterRoot, 'examples')); Directory subdirectory;
if (subdirectory.existsSync()) {
for (FileSystemEntity entry in subdirectory.listSync()) {
if (entry is Directory)
_addPackage(entry.path, dartFiles, pubSpecDirectories);
}
}
subdirectory = new Directory(path.join(ArtifactStore.flutterRoot, 'examples', 'layers')); subdirectory = new Directory(path.join(ArtifactStore.flutterRoot, 'examples', 'layers'));
if (subdirectory.existsSync()) { if (subdirectory.existsSync()) {
...@@ -198,7 +211,7 @@ class AnalyzeCommand extends FlutterCommand { ...@@ -198,7 +211,7 @@ class AnalyzeCommand extends FlutterCommand {
} }
dartFiles = dartFiles.map((String directory) => path.normalize(path.absolute(directory))).toSet().toList(); dartFiles = dartFiles.map((String directory) => path.normalize(path.absolute(directory))).toSet().toList();
dartFiles.sort(); dartFiles.sort();
// prepare a Dart file that references all the above Dart files // prepare a Dart file that references all the above Dart files
...@@ -444,4 +457,3 @@ linter: ...@@ -444,4 +457,3 @@ linter:
return 0; return 0;
} }
} }
...@@ -118,7 +118,7 @@ class DriveCommand extends RunCommandBase { ...@@ -118,7 +118,7 @@ class DriveCommand extends RunCommandBase {
await appStopper(this); await appStopper(this);
} catch(error, stackTrace) { } catch(error, stackTrace) {
// TODO(yjbanov): remove this guard when this bug is fixed: https://github.com/dart-lang/sdk/issues/25862 // TODO(yjbanov): remove this guard when this bug is fixed: https://github.com/dart-lang/sdk/issues/25862
printStatus('Could not stop application: $error\n$stackTrace'); printTrace('Could not stop application: $error\n$stackTrace');
} }
} else { } else {
printStatus('Leaving the application running.'); printStatus('Leaving the application running.');
......
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
{{#withDriverTest?}}
import 'package:flutter_driver/driver_extension.dart';
{{/withDriverTest?}}
void main() { void main() {
{{#withDriverTest?}}
// Starts the app with Flutter Driver extension enabled to allow Flutter Driver
// to test the app.
enableFlutterDriverExtension();
{{/withDriverTest?}}
runApp( runApp(
new MaterialApp( new MaterialApp(
title: 'Flutter Demo', title: 'Flutter Demo',
......
// Starts the app with Flutter Driver extension enabled to allow Flutter Driver
// to test the app.
import 'package:{{projectName}}/main.dart' as app;
import 'package:flutter_driver/driver_extension.dart';
main() {
enableFlutterDriverExtension();
app.main();
}
...@@ -22,28 +22,20 @@ main() { ...@@ -22,28 +22,20 @@ main() {
}); });
tearDownAll(() async { tearDownAll(() async {
if (driver != null) driver.close(); if (driver != null)
}); driver.close();
test('find the floating action button by value key', () async {
ObjectRef elem = await driver.findByValueKey('fab');
expect(elem, isNotNull);
expect(elem.objectReferenceKey, isNotNull);
}); });
test('tap on the floating action button; verify counter', () async { test('tap on the floating action button; verify counter', () async {
// Find floating action button (fab) to tap on // Find floating action button (fab) to tap on
ObjectRef fab = await driver.findByValueKey('fab'); ObjectRef fab = await driver.findByTooltipMessage('Increment');
expect(fab, isNotNull); expect(fab, isNotNull);
// Tap on the fab // Tap on the fab
await driver.tap(fab); await driver.tap(fab);
// Wait for text to change to the desired value // Wait for text to change to the desired value
await driver.waitFor(() async { expect(await driver.findByText('Button tapped 1 time.'), isNotNull);
ObjectRef counter = await driver.findByValueKey('counter');
return await driver.getText(counter);
}, contains("Button tapped 1 times."));
}); });
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment