Commit fea74965 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Add frameSync mechanism to flutter_driver. (#7471)

With frameSync enabled, flutter_driver actions will only be performed
when there are no pending frames in the app under test. This helps with
reducing flakiness.
parent f5bd8976
...@@ -3,6 +3,8 @@ import 'dart:async'; ...@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:flutter_driver/flutter_driver.dart'; import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
const Duration kWaitBetweenActions = const Duration(milliseconds: 250);
void main() { void main() {
group('flutter gallery transitions', () { group('flutter gallery transitions', () {
FlutterDriver driver; FlutterDriver driver;
...@@ -18,13 +20,13 @@ void main() { ...@@ -18,13 +20,13 @@ void main() {
test('navigation', () async { test('navigation', () async {
SerializableFinder menuItem = find.text('Text fields'); SerializableFinder menuItem = find.text('Text fields');
await driver.scrollIntoView(menuItem); await driver.scrollIntoView(menuItem);
await new Future<Null>.delayed(new Duration(milliseconds: 500)); await new Future<Null>.delayed(kWaitBetweenActions);
for (int i = 0; i < 15; i++) { for (int i = 0; i < 15; i++) {
await driver.tap(menuItem); await driver.tap(menuItem);
await new Future<Null>.delayed(new Duration(milliseconds: 1000)); await new Future<Null>.delayed(kWaitBetweenActions);
await driver.tap(find.byTooltip('Back')); await driver.tap(find.byTooltip('Back'));
await new Future<Null>.delayed(new Duration(milliseconds: 1000)); await new Future<Null>.delayed(kWaitBetweenActions);
} }
}, timeout: new Timeout(new Duration(minutes: 1))); }, timeout: new Timeout(new Duration(minutes: 1)));
}); });
......
...@@ -59,8 +59,15 @@ final List<String> demoTitles = <String>[ ...@@ -59,8 +59,15 @@ final List<String> demoTitles = <String>[
'Typography' 'Typography'
]; ];
// Subset of [demoTitles] that needs frameSync turned off.
final List<String> unsynchedDemoTitles = <String>[
'Progress indicators',
];
final FileSystem _fs = new LocalFileSystem(); final FileSystem _fs = new LocalFileSystem();
const Duration kWaitBetweenActions = const Duration(milliseconds: 250);
/// Extracts event data from [events] recorded by timeline, validates it, turns /// Extracts event data from [events] recorded by timeline, validates it, turns
/// it into a histogram, and saves to a JSON file. /// it into a histogram, and saves to a JSON file.
Future<Null> saveDurationsHistogram(List<Map<String, dynamic>> events, String outputPath) async { Future<Null> saveDurationsHistogram(List<Map<String, dynamic>> events, String outputPath) async {
...@@ -144,7 +151,7 @@ void main() { ...@@ -144,7 +151,7 @@ void main() {
// Expand the demo category submenus. // Expand the demo category submenus.
for (String category in demoCategories.reversed) { for (String category in demoCategories.reversed) {
await driver.tap(find.text(category)); await driver.tap(find.text(category));
await new Future<Null>.delayed(new Duration(milliseconds: 500)); await new Future<Null>.delayed(kWaitBetweenActions);
} }
// Scroll each demo menu item into view, launch the demo and // Scroll each demo menu item into view, launch the demo and
// return to the demo menu 2x. // return to the demo menu 2x.
...@@ -152,13 +159,20 @@ void main() { ...@@ -152,13 +159,20 @@ void main() {
print('Testing "$demoTitle" demo'); print('Testing "$demoTitle" demo');
SerializableFinder menuItem = find.text(demoTitle); SerializableFinder menuItem = find.text(demoTitle);
await driver.scrollIntoView(menuItem); await driver.scrollIntoView(menuItem);
await new Future<Null>.delayed(new Duration(milliseconds: 500)); await new Future<Null>.delayed(kWaitBetweenActions);
for(int i = 0; i < 2; i += 1) { for(int i = 0; i < 2; i += 1) {
await driver.tap(menuItem); // Launch the demo await driver.tap(menuItem); // Launch the demo
await new Future<Null>.delayed(new Duration(milliseconds: 500)); await new Future<Null>.delayed(kWaitBetweenActions);
await driver.tap(find.byTooltip('Back')); if (!unsynchedDemoTitles.contains(demoTitle)) {
await new Future<Null>.delayed(new Duration(milliseconds: 1000)); await driver.tap(find.byTooltip('Back'));
} else {
await driver.runUnsynchronized(() async {
await new Future<Null>.delayed(kWaitBetweenActions);
await driver.tap(find.byTooltip('Back'));
});
}
await new Future<Null>.delayed(kWaitBetweenActions);
} }
print('Success'); print('Success');
} }
......
...@@ -17,6 +17,7 @@ import 'health.dart'; ...@@ -17,6 +17,7 @@ import 'health.dart';
import 'input.dart'; import 'input.dart';
import 'message.dart'; import 'message.dart';
import 'render_tree.dart'; import 'render_tree.dart';
import 'frame_sync.dart';
import 'timeline.dart'; import 'timeline.dart';
/// Timeline stream identifier. /// Timeline stream identifier.
...@@ -395,6 +396,33 @@ class FlutterDriver { ...@@ -395,6 +396,33 @@ class FlutterDriver {
return stopTracingAndDownloadTimeline(); return stopTracingAndDownloadTimeline();
} }
/// [action] will be executed with the frame sync mechanism disabled.
///
/// By default, Flutter Driver waits until there is no pending frame scheduled
/// in the app under test before executing an action. This mechanism is called
/// "frame sync". It greatly reduces flakiness because Flutter Driver will not
/// execute an action while the app under test is undergoing a transition.
///
/// Having said that, sometimes it is necessary to disable the frame sync
/// mechanism (e.g. if there is an ongoing animation in the app, it will
/// never reach a state where there are no pending frames scheduled and the
/// action will time out). For these cases, the sync mechanism can be disabled
/// by wrapping the actions to be performed by this [runUnsynchronized] method.
///
/// With frame sync disabled, its the responsibility of the test author to
/// ensure that no action is performed while the app is undergoing a
/// transition to avoid flakiness.
Future<dynamic/*=T*/> runUnsynchronized/*<T>*/(Future<dynamic/*=T*/> action()) async {
await _sendCommand(new SetFrameSync(false));
dynamic/*=T*/ result;
try {
result = await action();
} finally {
await _sendCommand(new SetFrameSync(true));
}
return result;
}
/// Closes the underlying connection to the VM service. /// Closes the underlying connection to the VM service.
/// ///
/// Returns a [Future] that fires once the connection has been closed. /// Returns a [Future] that fires once the connection has been closed.
......
...@@ -18,6 +18,7 @@ import 'health.dart'; ...@@ -18,6 +18,7 @@ import 'health.dart';
import 'input.dart'; import 'input.dart';
import 'message.dart'; import 'message.dart';
import 'render_tree.dart'; import 'render_tree.dart';
import 'frame_sync.dart';
const String _extensionMethodName = 'driver'; const String _extensionMethodName = 'driver';
const String _extensionMethod = 'ext.flutter.$_extensionMethodName'; const String _extensionMethod = 'ext.flutter.$_extensionMethodName';
...@@ -65,6 +66,7 @@ class _FlutterDriverExtension { ...@@ -65,6 +66,7 @@ class _FlutterDriverExtension {
'get_render_tree': _getRenderTree, 'get_render_tree': _getRenderTree,
'tap': _tap, 'tap': _tap,
'get_text': _getText, 'get_text': _getText,
'set_frame_sync': _setFrameSync,
'scroll': _scroll, 'scroll': _scroll,
'scrollIntoView': _scrollIntoView, 'scrollIntoView': _scrollIntoView,
'setInputText': _setInputText, 'setInputText': _setInputText,
...@@ -73,15 +75,16 @@ class _FlutterDriverExtension { ...@@ -73,15 +75,16 @@ class _FlutterDriverExtension {
}); });
_commandDeserializers.addAll(<String, CommandDeserializerCallback>{ _commandDeserializers.addAll(<String, CommandDeserializerCallback>{
'get_health': (Map<String, dynamic> json) => new GetHealth.deserialize(json), 'get_health': (Map<String, String> params) => new GetHealth.deserialize(params),
'get_render_tree': (Map<String, dynamic> json) => new GetRenderTree.deserialize(json), 'get_render_tree': (Map<String, String> params) => new GetRenderTree.deserialize(params),
'tap': (Map<String, dynamic> json) => new Tap.deserialize(json), 'tap': (Map<String, String> params) => new Tap.deserialize(params),
'get_text': (Map<String, dynamic> json) => new GetText.deserialize(json), 'get_text': (Map<String, String> params) => new GetText.deserialize(params),
'scroll': (Map<String, dynamic> json) => new Scroll.deserialize(json), 'set_frame_sync': (Map<String, String> params) => new SetFrameSync.deserialize(params),
'scrollIntoView': (Map<String, dynamic> json) => new ScrollIntoView.deserialize(json), 'scroll': (Map<String, String> params) => new Scroll.deserialize(params),
'setInputText': (Map<String, dynamic> json) => new SetInputText.deserialize(json), 'scrollIntoView': (Map<String, String> params) => new ScrollIntoView.deserialize(params),
'submitInputText': (Map<String, dynamic> json) => new SubmitInputText.deserialize(json), 'setInputText': (Map<String, String> params) => new SetInputText.deserialize(params),
'waitFor': (Map<String, dynamic> json) => new WaitFor.deserialize(json), 'submitInputText': (Map<String, String> params) => new SubmitInputText.deserialize(params),
'waitFor': (Map<String, String> params) => new WaitFor.deserialize(params),
}); });
_finders.addAll(<String, FinderConstructor>{ _finders.addAll(<String, FinderConstructor>{
...@@ -96,6 +99,10 @@ class _FlutterDriverExtension { ...@@ -96,6 +99,10 @@ class _FlutterDriverExtension {
final Map<String, CommandDeserializerCallback> _commandDeserializers = <String, CommandDeserializerCallback>{}; final Map<String, CommandDeserializerCallback> _commandDeserializers = <String, CommandDeserializerCallback>{};
final Map<String, FinderConstructor> _finders = <String, FinderConstructor>{}; final Map<String, FinderConstructor> _finders = <String, FinderConstructor>{};
/// With [_frameSync] enabled, Flutter Driver will wait to perform an action
/// until there are no pending frames in the app under test.
bool _frameSync = true;
/// Processes a driver command configured by [params] and returns a result /// Processes a driver command configured by [params] and returns a result
/// as an arbitrary JSON object. /// as an arbitrary JSON object.
/// ///
...@@ -119,7 +126,7 @@ class _FlutterDriverExtension { ...@@ -119,7 +126,7 @@ class _FlutterDriverExtension {
return _makeResponse(response.toJson()); return _makeResponse(response.toJson());
} on TimeoutException catch (error, stackTrace) { } on TimeoutException catch (error, stackTrace) {
String msg = 'Timeout while executing $commandKind: $error\n$stackTrace'; String msg = 'Timeout while executing $commandKind: $error\n$stackTrace';
_log.error(msg); _log.error(msg);
return _makeResponse(msg, isError: true); return _makeResponse(msg, isError: true);
} catch (error, stackTrace) { } catch (error, stackTrace) {
String msg = 'Uncaught extension error while executing $commandKind: $error\n$stackTrace'; String msg = 'Uncaught extension error while executing $commandKind: $error\n$stackTrace';
...@@ -135,44 +142,36 @@ class _FlutterDriverExtension { ...@@ -135,44 +142,36 @@ class _FlutterDriverExtension {
}; };
} }
Stream<Duration> _onFrameReadyStream;
Stream<Duration> get _onFrameReady {
if (_onFrameReadyStream == null) {
// Lazy-initialize the frame callback because the renderer is not yet
// available at the time the extension is registered.
StreamController<Duration> frameReadyController = new StreamController<Duration>.broadcast(sync: true);
SchedulerBinding.instance.addPersistentFrameCallback((Duration timestamp) {
frameReadyController.add(timestamp);
});
_onFrameReadyStream = frameReadyController.stream;
}
return _onFrameReadyStream;
}
Future<Health> _getHealth(Command command) async => new Health(HealthStatus.ok); Future<Health> _getHealth(Command command) async => new Health(HealthStatus.ok);
Future<RenderTree> _getRenderTree(Command command) async { Future<RenderTree> _getRenderTree(Command command) async {
return new RenderTree(RendererBinding.instance?.renderView?.toStringDeep()); return new RenderTree(RendererBinding.instance?.renderView?.toStringDeep());
} }
// Waits until at the end of a frame the provided [condition] is [true].
Future<Null> _waitUntilFrame(bool condition(), [Completer<Null> completer]) {
completer ??= new Completer<Null>();
if (!condition()) {
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
_waitUntilFrame(condition, completer);
});
} else {
completer.complete();
}
return completer.future;
}
/// Runs `finder` repeatedly until it finds one or more [Element]s. /// Runs `finder` repeatedly until it finds one or more [Element]s.
Future<Finder> _waitForElement(Finder finder) { Future<Finder> _waitForElement(Finder finder) async {
// Short-circuit if the element is already on the UI if (_frameSync)
if (finder.precache()) await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0);
return new Future<Finder>.value(finder);
// No element yet, so we retry on frames rendered in the future.
Completer<Finder> completer = new Completer<Finder>();
StreamSubscription<Duration> subscription;
subscription = _onFrameReady.listen((Duration duration) {
if (finder.precache()) {
subscription.cancel();
completer.complete(finder);
}
});
return completer.future; await _waitUntilFrame(() => finder.precache());
if (_frameSync)
await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0);
return finder;
} }
Finder _createByTextFinder(ByText arguments) { Finder _createByTextFinder(ByText arguments) {
...@@ -242,7 +241,7 @@ class _FlutterDriverExtension { ...@@ -242,7 +241,7 @@ class _FlutterDriverExtension {
Future<ScrollResult> _scrollIntoView(Command command) async { Future<ScrollResult> _scrollIntoView(Command command) async {
ScrollIntoView scrollIntoViewCommand = command; ScrollIntoView scrollIntoViewCommand = command;
Finder target = await _waitForElement(_createFinder(scrollIntoViewCommand.finder)); Finder target = await _waitForElement(_createFinder(scrollIntoViewCommand.finder));
await Scrollable.ensureVisible(target.evaluate().single); await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100));
return new ScrollResult(); return new ScrollResult();
} }
...@@ -269,4 +268,10 @@ class _FlutterDriverExtension { ...@@ -269,4 +268,10 @@ class _FlutterDriverExtension {
Text text = target.evaluate().single.widget; Text text = target.evaluate().single.widget;
return new GetTextResult(text.data); return new GetTextResult(text.data);
} }
Future<SetFrameSyncResult> _setFrameSync(Command command) async {
SetFrameSync setFrameSyncCommand = command;
_frameSync = setFrameSyncCommand.enabled;
return new SetFrameSyncResult();
}
} }
// Copyright 2017 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';
/// Enables or disables the FrameSync mechanism.
class SetFrameSync extends Command {
@override
final String kind = 'set_frame_sync';
/// Whether frameSync should be enabled or disabled.
final bool enabled;
SetFrameSync(this.enabled) : super();
/// Deserializes this command from the value generated by [serialize].
SetFrameSync.deserialize(Map<String, String> params)
: this.enabled = params['enabled'].toLowerCase() == 'true',
super.deserialize(params);
@override
Map<String, String> serialize() => super.serialize()..addAll(<String, String>{
'enabled': '$enabled',
});
}
/// The result of a [SetFrameSync] command.
class SetFrameSyncResult extends Result {
/// Deserializes this result from JSON.
static SetFrameSyncResult fromJson(Map<String, dynamic> json) {
return new SetFrameSyncResult();
}
@override
Map<String, dynamic> toJson() => <String, dynamic>{};
}
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