Commit 7c9f9be3 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Add a timeout to every command (enforced on device and host) (#7391)

parent 1df639b4
...@@ -98,7 +98,7 @@ class FlutterDriver { ...@@ -98,7 +98,7 @@ class FlutterDriver {
static const String _kFlutterExtensionMethod = 'ext.flutter.driver'; static const String _kFlutterExtensionMethod = 'ext.flutter.driver';
static const String _kSetVMTimelineFlagsMethod = '_setVMTimelineFlags'; static const String _kSetVMTimelineFlagsMethod = '_setVMTimelineFlags';
static const String _kGetVMTimelineMethod = '_getVMTimeline'; static const String _kGetVMTimelineMethod = '_getVMTimeline';
static const Duration _kDefaultTimeout = const Duration(seconds: 5); static const Duration _kRpcGraceTime = const Duration(seconds: 2);
/// Connects to a Flutter application. /// Connects to a Flutter application.
/// ///
...@@ -227,10 +227,17 @@ class FlutterDriver { ...@@ -227,10 +227,17 @@ class FlutterDriver {
final VMIsolateRef _appIsolate; final VMIsolateRef _appIsolate;
Future<Map<String, dynamic>> _sendCommand(Command command) async { Future<Map<String, dynamic>> _sendCommand(Command command) async {
Map<String, String> parameters = <String, String>{'command': command.kind} Map<String, dynamic> response;
..addAll(command.serialize());
try { try {
return await _appIsolate.invokeExtension(_kFlutterExtensionMethod, parameters); response = await _appIsolate
.invokeExtension(_kFlutterExtensionMethod, command.serialize())
.timeout(command.timeout + _kRpcGraceTime);
} on TimeoutException catch (error, stackTrace) {
throw new DriverError(
'Failed to fulfill ${command.runtimeType}: Flutter application not responding',
error,
stackTrace
);
} catch (error, stackTrace) { } catch (error, stackTrace) {
throw new DriverError( throw new DriverError(
'Failed to fulfill ${command.runtimeType} due to remote error', 'Failed to fulfill ${command.runtimeType} due to remote error',
...@@ -238,6 +245,9 @@ class FlutterDriver { ...@@ -238,6 +245,9 @@ class FlutterDriver {
stackTrace stackTrace
); );
} }
if (response['isError'])
throw new DriverError('Error in Flutter application: ${response['response']}');
return response['response'];
} }
/// Checks the status of the Flutter Driver extension. /// Checks the status of the Flutter Driver extension.
...@@ -275,7 +285,7 @@ class FlutterDriver { ...@@ -275,7 +285,7 @@ class FlutterDriver {
} }
/// Waits until [finder] locates the target. /// Waits until [finder] locates the target.
Future<Null> waitFor(SerializableFinder finder, {Duration timeout: _kDefaultTimeout}) async { Future<Null> waitFor(SerializableFinder finder, {Duration timeout}) async {
await _sendCommand(new WaitFor(finder, timeout: timeout)); await _sendCommand(new WaitFor(finder, timeout: timeout));
return null; return null;
} }
......
...@@ -74,15 +74,15 @@ class _FlutterDriverExtension { ...@@ -74,15 +74,15 @@ class _FlutterDriverExtension {
}); });
_commandDeserializers.addAll(<String, CommandDeserializerCallback>{ _commandDeserializers.addAll(<String, CommandDeserializerCallback>{
'get_health': GetHealth.deserialize, 'get_health': (Map<String, dynamic> json) => new GetHealth.deserialize(json),
'get_render_tree': GetRenderTree.deserialize, 'get_render_tree': (Map<String, dynamic> json) => new GetRenderTree.deserialize(json),
'tap': Tap.deserialize, 'tap': (Map<String, dynamic> json) => new Tap.deserialize(json),
'get_text': GetText.deserialize, 'get_text': (Map<String, dynamic> json) => new GetText.deserialize(json),
'scroll': Scroll.deserialize, 'scroll': (Map<String, dynamic> json) => new Scroll.deserialize(json),
'scrollIntoView': ScrollIntoView.deserialize, 'scrollIntoView': (Map<String, dynamic> json) => new ScrollIntoView.deserialize(json),
'setInputText': SetInputText.deserialize, 'setInputText': (Map<String, dynamic> json) => new SetInputText.deserialize(json),
'submitInputText': SubmitInputText.deserialize, 'submitInputText': (Map<String, dynamic> json) => new SubmitInputText.deserialize(json),
'waitFor': WaitFor.deserialize, 'waitFor': (Map<String, dynamic> json) => new WaitFor.deserialize(json),
}); });
_finders.addAll(<String, FinderConstructor>{ _finders.addAll(<String, FinderConstructor>{
...@@ -108,21 +108,34 @@ class _FlutterDriverExtension { ...@@ -108,21 +108,34 @@ class _FlutterDriverExtension {
/// The returned JSON is command specific. Generally the caller deserializes /// The returned JSON is command specific. Generally the caller deserializes
/// the result into a subclass of [Result], but that's not strictly required. /// the result into a subclass of [Result], but that's not strictly required.
Future<Map<String, dynamic>> call(Map<String, String> params) async { Future<Map<String, dynamic>> call(Map<String, String> params) async {
String commandKind = params['command'];
try { try {
String commandKind = params['command'];
CommandHandlerCallback commandHandler = _commandHandlers[commandKind]; CommandHandlerCallback commandHandler = _commandHandlers[commandKind];
CommandDeserializerCallback commandDeserializer = CommandDeserializerCallback commandDeserializer =
_commandDeserializers[commandKind]; _commandDeserializers[commandKind];
if (commandHandler == null || commandDeserializer == null) if (commandHandler == null || commandDeserializer == null)
throw 'Extension $_extensionMethod does not support command $commandKind'; throw 'Extension $_extensionMethod does not support command $commandKind';
Command command = commandDeserializer(params); Command command = commandDeserializer(params);
return (await commandHandler(command)).toJson(); Result response = await commandHandler(command).timeout(command.timeout);
return _makeResponse(response.toJson());
} on TimeoutException catch (error, stackTrace) {
String msg = 'Timeout while executing $commandKind: $error\n$stackTrace';
_log.error(msg);
return _makeResponse(msg, isError: true);
} catch (error, stackTrace) { } catch (error, stackTrace) {
_log.error('Uncaught extension error: $error\n$stackTrace'); String msg = 'Uncaught extension error while executing $commandKind: $error\n$stackTrace';
rethrow; _log.error(msg);
return _makeResponse(msg, isError: true);
} }
} }
Map<String, dynamic> _makeResponse(dynamic response, {bool isError: false}) {
return <String, dynamic>{
'isError': isError,
'response': response,
};
}
Stream<Duration> _onFrameReadyStream; Stream<Duration> _onFrameReadyStream;
Stream<Duration> get _onFrameReady { Stream<Duration> get _onFrameReady {
if (_onFrameReadyStream == null) { if (_onFrameReadyStream == null) {
......
...@@ -20,11 +20,16 @@ DriverError _createInvalidKeyValueTypeError(String invalidType) { ...@@ -20,11 +20,16 @@ DriverError _createInvalidKeyValueTypeError(String invalidType) {
/// and add more keys to the returned map. /// and add more keys to the returned map.
abstract class CommandWithTarget extends Command { abstract class CommandWithTarget extends Command {
/// Constructs this command given a [finder]. /// Constructs this command given a [finder].
CommandWithTarget(this.finder) { CommandWithTarget(this.finder, {Duration timeout}) : super(timeout: timeout) {
if (finder == null) if (finder == null)
throw new DriverError('${this.runtimeType} target cannot be null'); throw new DriverError('${this.runtimeType} target cannot be null');
} }
/// Deserializes the command from JSON generated by [serialize].
CommandWithTarget.deserialize(Map<String, String> json)
: finder = SerializableFinder.deserialize(json),
super.deserialize(json);
/// Locates the object or objects targeted by this command. /// Locates the object or objects targeted by this command.
final SerializableFinder finder; final SerializableFinder finder;
...@@ -37,7 +42,8 @@ abstract class CommandWithTarget extends Command { ...@@ -37,7 +42,8 @@ abstract class CommandWithTarget extends Command {
/// 'foo': this.foo, /// 'foo': this.foo,
/// }); /// });
@override @override
Map<String, String> serialize() => finder.serialize(); Map<String, String> serialize() =>
super.serialize()..addAll(finder.serialize());
} }
/// Waits until [finder] can locate the target. /// Waits until [finder] can locate the target.
...@@ -49,33 +55,11 @@ class WaitFor extends CommandWithTarget { ...@@ -49,33 +55,11 @@ class WaitFor extends CommandWithTarget {
/// appear within the [timeout] amount of time. /// appear within the [timeout] amount of time.
/// ///
/// If [timeout] is not specified the command times out after 5 seconds. /// If [timeout] is not specified the command times out after 5 seconds.
WaitFor(SerializableFinder finder, {this.timeout}) WaitFor(SerializableFinder finder, {Duration timeout})
: super(finder); : super(finder, timeout: timeout);
/// The maximum amount of time to wait for the [finder] to locate the desired
/// widgets before timing out the command.
///
/// Defaults to 5 seconds.
final Duration timeout;
/// Deserializes the command from JSON generated by [serialize]. /// Deserializes the command from JSON generated by [serialize].
static WaitFor deserialize(Map<String, String> json) { WaitFor.deserialize(Map<String, String> json) : super.deserialize(json);
Duration timeout = json['timeout'] != null
? new Duration(milliseconds: int.parse(json['timeout']))
: null;
return new WaitFor(SerializableFinder.deserialize(json), timeout: timeout);
}
@override
Map<String, String> serialize() {
Map<String, String> json = super.serialize();
if (timeout != null) {
json['timeout'] = '${timeout.inMilliseconds}';
}
return json;
}
} }
/// The result of a [WaitFor] command. /// The result of a [WaitFor] command.
...@@ -207,16 +191,14 @@ class ByValueKey extends SerializableFinder { ...@@ -207,16 +191,14 @@ class ByValueKey extends SerializableFinder {
/// Command to read the text from a given element. /// Command to read the text from a given element.
class GetText extends CommandWithTarget { class GetText extends CommandWithTarget {
/// [finder] looks for an element that contains a piece of text.
GetText(SerializableFinder finder) : super(finder);
@override @override
final String kind = 'get_text'; final String kind = 'get_text';
/// [finder] looks for an element that contains a piece of text.
GetText(SerializableFinder finder) : super(finder);
/// Deserializes the command from JSON generated by [serialize]. /// Deserializes the command from JSON generated by [serialize].
static GetText deserialize(Map<String, String> json) { GetText.deserialize(Map<String, dynamic> json) : super.deserialize(json);
return new GetText(SerializableFinder.deserialize(json));
}
@override @override
Map<String, String> serialize() => super.serialize(); Map<String, String> serialize() => super.serialize();
......
...@@ -14,9 +14,7 @@ class Tap extends CommandWithTarget { ...@@ -14,9 +14,7 @@ class Tap extends CommandWithTarget {
Tap(SerializableFinder finder) : super(finder); Tap(SerializableFinder finder) : super(finder);
/// Deserializes this command from JSON generated by [serialize]. /// Deserializes this command from JSON generated by [serialize].
static Tap deserialize(Map<String, String> json) { Tap.deserialize(Map<String, String> json) : super.deserialize(json);
return new Tap(SerializableFinder.deserialize(json));
}
@override @override
Map<String, String> serialize() => super.serialize(); Map<String, String> serialize() => super.serialize();
...@@ -50,15 +48,12 @@ class Scroll extends CommandWithTarget { ...@@ -50,15 +48,12 @@ class Scroll extends CommandWithTarget {
) : super(finder); ) : super(finder);
/// Deserializes this command from JSON generated by [serialize]. /// Deserializes this command from JSON generated by [serialize].
static Scroll deserialize(Map<String, dynamic> json) { Scroll.deserialize(Map<String, dynamic> json)
return new Scroll( : this.dx = double.parse(json['dx']),
SerializableFinder.deserialize(json), this.dy = double.parse(json['dy']),
double.parse(json['dx']), this.duration = new Duration(microseconds: int.parse(json['duration'])),
double.parse(json['dy']), this.frequency = int.parse(json['frequency']),
new Duration(microseconds: int.parse(json['duration'])), super.deserialize(json);
int.parse(json['frequency'])
);
}
/// Delta X offset per move event. /// Delta X offset per move event.
final double dx; final double dx;
...@@ -103,9 +98,8 @@ class ScrollIntoView extends CommandWithTarget { ...@@ -103,9 +98,8 @@ class ScrollIntoView extends CommandWithTarget {
ScrollIntoView(SerializableFinder finder) : super(finder); ScrollIntoView(SerializableFinder finder) : super(finder);
/// Deserializes this command from JSON generated by [serialize]. /// Deserializes this command from JSON generated by [serialize].
static ScrollIntoView deserialize(Map<String, dynamic> json) { ScrollIntoView.deserialize(Map<String, dynamic> json)
return new ScrollIntoView(SerializableFinder.deserialize(json)); : super.deserialize(json);
}
// This is here just to be clear that this command isn't adding any extra // This is here just to be clear that this command isn't adding any extra
// fields. // fields.
......
...@@ -6,15 +6,14 @@ import 'enum_util.dart'; ...@@ -6,15 +6,14 @@ import 'enum_util.dart';
import 'message.dart'; import 'message.dart';
/// Requests an application health check. /// Requests an application health check.
class GetHealth implements Command { class GetHealth extends Command {
@override @override
final String kind = 'get_health'; final String kind = 'get_health';
/// Deserializes the command from JSON generated by [serialize]. GetHealth({Duration timeout}) : super(timeout: timeout);
static GetHealth deserialize(Map<String, String> json) => new GetHealth();
@override /// Deserializes the command from JSON generated by [serialize].
Map<String, String> serialize() => const <String, String>{}; GetHealth.deserialize(Map<String, String> json) : super.deserialize(json);
} }
/// Application health status. /// Application health status.
......
...@@ -20,10 +20,9 @@ class SetInputText extends CommandWithTarget { ...@@ -20,10 +20,9 @@ class SetInputText extends CommandWithTarget {
final String text; final String text;
/// Deserializes this command from JSON generated by [serialize]. /// Deserializes this command from JSON generated by [serialize].
static SetInputText deserialize(Map<String, dynamic> json) { SetInputText.deserialize(Map<String, dynamic> json)
String text = json['text']; : this.text = json['text'],
return new SetInputText(SerializableFinder.deserialize(json), text); super.deserialize(json);
}
@override @override
Map<String, String> serialize() { Map<String, String> serialize() {
...@@ -56,9 +55,8 @@ class SubmitInputText extends CommandWithTarget { ...@@ -56,9 +55,8 @@ class SubmitInputText extends CommandWithTarget {
SubmitInputText(SerializableFinder finder) : super(finder); SubmitInputText(SerializableFinder finder) : super(finder);
/// Deserializes this command from JSON generated by [serialize]. /// Deserializes this command from JSON generated by [serialize].
static SubmitInputText deserialize(Map<String, dynamic> json) { SubmitInputText.deserialize(Map<String, dynamic> json)
return new SubmitInputText(SerializableFinder.deserialize(json)); : super.deserialize(json);
}
} }
/// The result of a [SubmitInputText] command. /// The result of a [SubmitInputText] command.
......
...@@ -5,11 +5,25 @@ ...@@ -5,11 +5,25 @@
/// An object sent from the Flutter Driver to a Flutter application to instruct /// An object sent from the Flutter Driver to a Flutter application to instruct
/// the application to perform a task. /// the application to perform a task.
abstract class Command { abstract class Command {
Command({Duration timeout})
: this.timeout = timeout ?? const Duration(seconds: 5);
Command.deserialize(Map<String, String> json)
: timeout = new Duration(milliseconds: int.parse(json['timeout']));
/// The maximum amount of time to wait for the command to complete.
///
/// Defaults to 5 seconds.
final Duration timeout;
/// Identifies the type of the command object and of the handler. /// Identifies the type of the command object and of the handler.
String get kind; String get kind;
/// Serializes this command to parameter name/value pairs. /// Serializes this command to parameter name/value pairs.
Map<String, String> serialize(); Map<String, String> serialize() => <String, String>{
'command': kind,
'timeout': '${timeout.inMilliseconds}',
};
} }
/// An object sent from a Flutter application back to the Flutter Driver in /// An object sent from a Flutter application back to the Flutter Driver in
......
...@@ -5,15 +5,14 @@ ...@@ -5,15 +5,14 @@
import 'message.dart'; import 'message.dart';
/// A request for a string representation of the render tree. /// A request for a string representation of the render tree.
class GetRenderTree implements Command { class GetRenderTree extends Command {
@override @override
final String kind = 'get_render_tree'; final String kind = 'get_render_tree';
/// Deserializes the command from JSON generated by [serialize]. GetRenderTree({Duration timeout}) : super(timeout: timeout);
static GetRenderTree deserialize(Map<String, String> json) => new GetRenderTree();
@override /// Deserializes the command from JSON generated by [serialize].
Map<String, String> serialize() => const <String, String>{}; GetRenderTree.deserialize(Map<String, String> json) : super.deserialize(json);
} }
/// A string representation of the render tree. /// A string representation of the render tree.
...@@ -34,4 +33,3 @@ class RenderTree extends Result { ...@@ -34,4 +33,3 @@ class RenderTree extends Result {
'tree': tree 'tree': tree
}; };
} }
...@@ -35,7 +35,7 @@ void main() { ...@@ -35,7 +35,7 @@ void main() {
when(mockVM.isolates).thenReturn(<VMRunnableIsolate>[mockIsolate]); when(mockVM.isolates).thenReturn(<VMRunnableIsolate>[mockIsolate]);
when(mockIsolate.loadRunnable()).thenReturn(mockIsolate); when(mockIsolate.loadRunnable()).thenReturn(mockIsolate);
when(mockIsolate.invokeExtension(any, any)) when(mockIsolate.invokeExtension(any, any))
.thenReturn(new Future<Map<String, dynamic>>.value(<String, String>{'status': 'ok'})); .thenReturn(makeMockResponse(<String, dynamic>{'status': 'ok'}));
vmServiceConnectFunction = (String url) { vmServiceConnectFunction = (String url) {
return new Future<VMServiceClientConnection>.value( return new Future<VMServiceClientConnection>.value(
new VMServiceClientConnection(mockClient, null) new VMServiceClientConnection(mockClient, null)
...@@ -106,9 +106,8 @@ void main() { ...@@ -106,9 +106,8 @@ void main() {
}); });
test('checks the health of the driver extension', () async { test('checks the health of the driver extension', () async {
when(mockIsolate.invokeExtension(any, any)).thenReturn(new Future<Map<String, dynamic>>.value(<String, dynamic>{ when(mockIsolate.invokeExtension(any, any)).thenReturn(
'status': 'ok', makeMockResponse(<String, dynamic>{'status': 'ok'}));
}));
Health result = await driver.checkHealth(); Health result = await driver.checkHealth();
expect(result.status, HealthStatus.ok); expect(result.status, HealthStatus.ok);
}); });
...@@ -128,11 +127,12 @@ void main() { ...@@ -128,11 +127,12 @@ void main() {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], <String, String>{ expect(i.positionalArguments[1], <String, String>{
'command': 'tap', 'command': 'tap',
'timeout': '5000',
'finderType': 'ByValueKey', 'finderType': 'ByValueKey',
'keyValueString': 'foo', 'keyValueString': 'foo',
'keyValueType': 'String' 'keyValueType': 'String'
}); });
return new Future<Null>.value(); return makeMockResponse(<String, dynamic>{});
}); });
await driver.tap(find.byValueKey('foo')); await driver.tap(find.byValueKey('foo'));
}); });
...@@ -147,10 +147,11 @@ void main() { ...@@ -147,10 +147,11 @@ void main() {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], <String, dynamic>{ expect(i.positionalArguments[1], <String, dynamic>{
'command': 'tap', 'command': 'tap',
'timeout': '5000',
'finderType': 'ByText', 'finderType': 'ByText',
'text': 'foo', 'text': 'foo',
}); });
return new Future<Map<String, dynamic>>.value(); return makeMockResponse(<String, dynamic>{});
}); });
await driver.tap(find.text('foo')); await driver.tap(find.text('foo'));
}); });
...@@ -165,11 +166,12 @@ void main() { ...@@ -165,11 +166,12 @@ void main() {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], <String, dynamic>{ expect(i.positionalArguments[1], <String, dynamic>{
'command': 'get_text', 'command': 'get_text',
'timeout': '5000',
'finderType': 'ByValueKey', 'finderType': 'ByValueKey',
'keyValueString': '123', 'keyValueString': '123',
'keyValueType': 'int' 'keyValueType': 'int'
}); });
return new Future<Map<String, dynamic>>.value(<String, String>{ return makeMockResponse(<String, String>{
'text': 'hello' 'text': 'hello'
}); });
}); });
...@@ -191,7 +193,7 @@ void main() { ...@@ -191,7 +193,7 @@ void main() {
'text': 'foo', 'text': 'foo',
'timeout': '1000', 'timeout': '1000',
}); });
return new Future<Map<String, dynamic>>.value(<String, dynamic>{}); return makeMockResponse(<String, dynamic>{});
}); });
await driver.waitFor(find.byTooltip('foo'), timeout: new Duration(seconds: 1)); await driver.waitFor(find.byTooltip('foo'), timeout: new Duration(seconds: 1));
}); });
...@@ -279,6 +281,45 @@ void main() { ...@@ -279,6 +281,45 @@ void main() {
expect(timeline.events.single.name, 'test event'); expect(timeline.events.single.name, 'test event');
}); });
}); });
group('sendCommand error conditions', () {
test('local timeout', () async {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
// completer never competed to trigger timeout
return new Completer<Map<String, dynamic>>().future;
});
try {
await driver.waitFor(find.byTooltip('foo'), timeout: new Duration(milliseconds: 100));
fail('expected an exception');
} catch(error) {
expect(error is DriverError, isTrue);
expect(error.message, 'Failed to fulfill WaitFor: Flutter application not responding');
}
});
test('remote error', () async {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
return makeMockResponse(<String, dynamic>{
'message': 'This is a failure'
}, isError: true);
});
try {
await driver.waitFor(find.byTooltip('foo'));
fail('expected an exception');
} catch(error) {
expect(error is DriverError, isTrue);
expect(error.message, 'Error in Flutter application: {message: This is a failure}');
}
});
});
});
}
Future<Map<String, dynamic>> makeMockResponse(
Map<String, dynamic> response, {bool isError: false}) {
return new Future<Map<String, dynamic>>.value(<String, dynamic>{
'isError': isError,
'response': response
}); });
} }
......
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