Unverified Commit a0099a90 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Make gallery tests more robust (#15957)

parent 8ca99327
...@@ -14,6 +14,8 @@ class Category { ...@@ -14,6 +14,8 @@ class Category {
const Category({ this.title, this.assets }); const Category({ this.title, this.assets });
final String title; final String title;
final List<String> assets; final List<String> assets;
@override
String toString() => '$runtimeType("$title")';
} }
const List<Category> allCategories = const <Category>[ const List<Category> allCategories = const <Category>[
...@@ -178,10 +180,13 @@ class BackdropPanel extends StatelessWidget { ...@@ -178,10 +180,13 @@ class BackdropPanel extends StatelessWidget {
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: new DefaultTextStyle( child: new DefaultTextStyle(
style: theme.textTheme.subhead, style: theme.textTheme.subhead,
child: new Tooltip(
message: 'Tap to dismiss',
child: title, child: title,
), ),
), ),
), ),
),
const Divider(height: 1.0), const Divider(height: 1.0),
new Expanded(child: child), new Expanded(child: child),
], ],
......
...@@ -118,7 +118,7 @@ List<GalleryItem> _buildGalleryItems() { ...@@ -118,7 +118,7 @@ List<GalleryItem> _buildGalleryItems() {
), ),
new GalleryItem( new GalleryItem(
title: 'Data tables', title: 'Data tables',
subtitle: 'Data tables', subtitle: 'Rows and columns',
category: 'Material Components', category: 'Material Components',
routeName: DataTableDemo.routeName, routeName: DataTableDemo.routeName,
buildRoute: (BuildContext context) => new DataTableDemo(), buildRoute: (BuildContext context) => new DataTableDemo(),
......
...@@ -11,30 +11,55 @@ import 'package:flutter/scheduler.dart'; ...@@ -11,30 +11,55 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gallery/gallery/app.dart'; import 'package:flutter_gallery/gallery/app.dart';
import 'package:flutter_gallery/gallery/item.dart';
/// Reports success or failure to the native code. // Reports success or failure to the native code.
const MethodChannel _kTestChannel = const MethodChannel('io.flutter.demo.gallery/TestLifecycleListener'); const MethodChannel _kTestChannel = const MethodChannel('io.flutter.demo.gallery/TestLifecycleListener');
// The titles for all of the Gallery demos.
final List<String> _kAllDemos = kAllGalleryItems.map((GalleryItem item) => item.title).toList();
// We don't want to wait for animations to complete before tapping the
// back button in the demos with these titles.
const List<String> _kUnsynchronizedDemos = const <String>[
'Progress indicators',
'Activity Indicator',
'Video',
];
// These demos can't be backed out of by tapping a button whose
// tooltip is 'Back'.
const List<String> _kSkippedDemos = const <String>[
'Backdrop',
'Pull to refresh',
];
Future<Null> main() async { Future<Null> main() async {
try { try {
runApp(const GalleryApp()); // Verify that _kUnsynchronizedDemos and _kSkippedDemos identify
// demos that actually exist.
if (!new Set<String>.from(_kAllDemos).containsAll(_kUnsynchronizedDemos))
fail('Unrecognized demo names in _kUnsynchronizedDemos: $_kUnsynchronizedDemos');
if (!new Set<String>.from(_kAllDemos).containsAll(_kSkippedDemos))
fail('Unrecognized demo names in _kSkippedDemos: $_kSkippedDemos');
const Duration kWaitBetweenActions = const Duration(milliseconds: 250); runApp(const GalleryApp());
final _LiveWidgetController controller = new _LiveWidgetController(); final _LiveWidgetController controller = new _LiveWidgetController();
for (String demo in _kAllDemos) {
for (Demo demo in demos) { print('Testing "$demo" demo');
print('Testing "${demo.title}" demo'); final Finder menuItem = find.text(demo);
final Finder menuItem = find.text(demo.title);
await controller.scrollIntoView(menuItem, alignment: 0.5); await controller.scrollIntoView(menuItem, alignment: 0.5);
await new Future<Null>.delayed(kWaitBetweenActions);
if (_kSkippedDemos.contains(demo)) {
print('> skipped $demo');
continue;
}
for (int i = 0; i < 2; i += 1) { for (int i = 0; i < 2; i += 1) {
await controller.tap(menuItem); // Launch the demo await controller.tap(menuItem); // Launch the demo
await new Future<Null>.delayed(kWaitBetweenActions); controller.frameSync = !_kUnsynchronizedDemos.contains(demo);
controller.frameSync = demo.synchronized;
await controller.tap(find.byTooltip('Back')); await controller.tap(find.byTooltip('Back'));
controller.frameSync = true; controller.frameSync = true;
await new Future<Null>.delayed(kWaitBetweenActions);
} }
print('Success'); print('Success');
} }
...@@ -45,70 +70,6 @@ Future<Null> main() async { ...@@ -45,70 +70,6 @@ Future<Null> main() async {
} }
} }
class Demo {
const Demo(this.title, {this.synchronized = true});
/// The title of the demo.
final String title;
/// True if frameSync should be enabled for this test.
final bool synchronized;
}
// Warning: this list must be kept in sync with the value of
// kAllGalleryItems.map((GalleryItem item) => item.title).toList();
const List<Demo> demos = const <Demo>[
// Demos
const Demo('Shrine'),
const Demo('Contact profile'),
const Demo('Animation'),
// Material Components
const Demo('Bottom navigation'),
const Demo('Buttons'),
const Demo('Cards'),
const Demo('Chips'),
const Demo('Date and time pickers'),
const Demo('Dialog'),
const Demo('Drawer'),
const Demo('Expand/collapse list control'),
const Demo('Expansion panels'),
const Demo('Floating action button'),
const Demo('Grid'),
const Demo('Icons'),
const Demo('Leave-behind list items'),
const Demo('List'),
const Demo('Menus'),
const Demo('Modal bottom sheet'),
const Demo('Page selector'),
const Demo('Persistent bottom sheet'),
const Demo('Progress indicators', synchronized: false),
const Demo('Pull to refresh'),
const Demo('Scrollable tabs'),
const Demo('Selection controls'),
const Demo('Sliders'),
const Demo('Snackbar'),
const Demo('Tabs'),
const Demo('Text fields'),
const Demo('Tooltips'),
// Cupertino Components
const Demo('Activity Indicator', synchronized: false),
const Demo('Buttons'),
const Demo('Dialogs'),
const Demo('Navigation'),
const Demo('Sliders'),
const Demo('Switches'),
// Media
const Demo('Animated images'),
// Style
const Demo('Colors'),
const Demo('Typography'),
];
class _LiveWidgetController { class _LiveWidgetController {
final WidgetController _controller = new WidgetController(WidgetsBinding.instance); final WidgetController _controller = new WidgetController(WidgetsBinding.instance);
......
...@@ -102,11 +102,19 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async { ...@@ -102,11 +102,19 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
// This demo's back button isn't initially visible.
if (routeName == '/material/backdrop') {
await tester.tap(find.byTooltip('Tap to dismiss'));
await tester.pumpAndSettle();
}
// Go back // Go back
await tester.pageBack(); await tester.pageBack();
await tester.pumpAndSettle();
await tester.pump(); // Start the pop "back" operation. await tester.pump(); // Start the pop "back" operation.
await tester.pump(); // Complete the willPop() Future. await tester.pump(); // Complete the willPop() Future.
await tester.pump(const Duration(milliseconds: 400)); // Wait until it has finished. await tester.pump(const Duration(milliseconds: 400)); // Wait until it has finished.
return null; return null;
} }
...@@ -126,8 +134,6 @@ Future<Null> runSmokeTest(WidgetTester tester) async { ...@@ -126,8 +134,6 @@ Future<Null> runSmokeTest(WidgetTester tester) async {
final Finder finder = findGalleryItemByRouteName(tester, routeName); final Finder finder = findGalleryItemByRouteName(tester, routeName);
Scrollable.ensureVisible(tester.element(finder), alignment: 0.5); Scrollable.ensureVisible(tester.element(finder), alignment: 0.5);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
if (routeName == '/material/backdrop')
continue;
await smokeDemo(tester, routeName); await smokeDemo(tester, routeName);
tester.binding.debugAssertNoTransientCallbacks('A transient callback was still active after leaving route $routeName'); tester.binding.debugAssertNoTransientCallbacks('A transient callback was still active after leaving route $routeName');
} }
......
...@@ -23,18 +23,14 @@ void main() { ...@@ -23,18 +23,14 @@ void main() {
final SerializableFinder menuItem = find.text('Text fields'); final SerializableFinder menuItem = find.text('Text fields');
driver.waitFor(menuItem).then<Null>((Null value) async { driver.waitFor(menuItem).then<Null>((Null value) async {
scroll = false; scroll = false;
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(kWaitBetweenActions);
await driver.tap(find.byTooltip('Back')); await driver.tap(find.byTooltip('Back'));
await new Future<Null>.delayed(kWaitBetweenActions);
} }
completer.complete(); completer.complete();
}); });
while (scroll) { while (scroll) {
await driver.scroll(find.text('Flutter Gallery'), 0.0, -500.0, const Duration(milliseconds: 80)); await driver.scroll(find.text('Flutter Gallery'), 0.0, -500.0, const Duration(milliseconds: 80));
await new Future<Null>.delayed(kWaitBetweenActions);
} }
await completer.future; await completer.future;
}, timeout: const Timeout(const Duration(minutes: 1))); }, timeout: const Timeout(const Duration(minutes: 1)));
......
...@@ -2,10 +2,21 @@ ...@@ -2,10 +2,21 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'dart:convert' show JsonEncoder;
import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_driver/driver_extension.dart';
import 'package:flutter_gallery/gallery/item.dart';
import 'package:flutter_gallery/main.dart' as app; import 'package:flutter_gallery/main.dart' as app;
Future<String> _handleMessages(String message) async {
assert(message == 'demoNames');
return const JsonEncoder.withIndent(' ').convert(
kAllGalleryItems.map((GalleryItem item) => item.title).toList(),
);
}
void main() { void main() {
enableFlutterDriverExtension(); enableFlutterDriverExtension(handler: _handleMessages);
app.main(); app.main();
} }
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:convert' show JsonEncoder; import 'dart:convert' show JsonEncoder, JsonDecoder;
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
...@@ -11,81 +11,46 @@ import 'package:flutter_driver/flutter_driver.dart'; ...@@ -11,81 +11,46 @@ import 'package:flutter_driver/flutter_driver.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:test/test.dart'; import 'package:test/test.dart';
class Demo { const FileSystem _fs = const LocalFileSystem();
const Demo(this.title, {this.synchronized = true, this.profiled = false});
/// The title of the demo.
final String title;
/// True if frameSync should be enabled for this test.
final bool synchronized;
// True if timeline data should be collected for this test.
//
// Warning: The number of tests executed with timeline collection enabled
// significantly impacts heap size of the running app. When run with
// --trace-startup, as we do in this test, the VM stores trace events in an
// endless buffer instead of a ring buffer.
final bool profiled;
}
// Warning: this list must be kept in sync with the value of // Demos for which timeline data will be collected using
// kAllGalleryItems.map((GalleryItem item) => item.title).toList(); // FlutterDriver.traceAction().
const List<Demo> demos = const <Demo>[ //
// Demos // Warning: The number of tests executed with timeline collection enabled
const Demo('Shrine', profiled: true), // significantly impacts heap size of the running app. When run with
const Demo('Contact profile', profiled: true), // --trace-startup, as we do in this test, the VM stores trace events in an
const Demo('Animation', profiled: true), // endless buffer instead of a ring buffer.
//
// Material Components // These names must match GalleryItem titles from kAllGalleryItems
const Demo('Bottom navigation', profiled: true), // in examples/flutter_gallery/lib/gallery.item.dart
const Demo('Buttons', profiled: true), const List<String> kProfiledDemos = const <String>[
const Demo('Cards', profiled: true), 'Shrine',
const Demo('Chips', profiled: true), 'Contact profile',
const Demo('Date and time pickers', profiled: true), 'Animation',
const Demo('Dialog', profiled: true), 'Bottom navigation',
const Demo('Drawer'), 'Buttons',
const Demo('Expand/collapse list control'), 'Cards',
const Demo('Expansion panels'), 'Chips',
const Demo('Floating action button'), 'Date and time pickers',
const Demo('Grid'), 'Dialog',
const Demo('Icons'),
const Demo('Leave-behind list items'),
const Demo('List'),
const Demo('Menus'),
const Demo('Modal bottom sheet'),
const Demo('Page selector'),
const Demo('Persistent bottom sheet'),
const Demo('Progress indicators', synchronized: false),
const Demo('Pull to refresh'),
const Demo('Scrollable tabs'),
const Demo('Selection controls'),
const Demo('Sliders'),
const Demo('Snackbar'),
const Demo('Tabs'),
const Demo('Text fields'),
const Demo('Tooltips'),
// Cupertino Components
const Demo('Activity Indicator', synchronized: false),
const Demo('Buttons'),
const Demo('Dialogs'),
const Demo('Navigation'),
const Demo('Pickers'),
const Demo('Sliders'),
const Demo('Switches'),
// Media
const Demo('Animated images'),
// Style
const Demo('Colors'),
const Demo('Typography'),
]; ];
const FileSystem _fs = const LocalFileSystem(); // Demos that will be backed out of within FlutterDriver.runUnsynchronized();
//
// These names must match GalleryItem titles from kAllGalleryItems
// in examples/flutter_gallery/lib/gallery.item.dart
const List<String> kUnsynchronizedDemos = const <String>[
'Progress indicators',
'Activity Indicator',
'Video',
];
const Duration kWaitBetweenActions = const Duration(milliseconds: 250); // All of the gallery demo titles in the order they appear on the
// gallery home page.
//
// These names are reported by the test app, see _handleMessages()
// in transitions_perf.dart.
List<String> _allDemos = <String>[];
/// 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.
...@@ -155,25 +120,29 @@ Future<Null> saveDurationsHistogram(List<Map<String, dynamic>> events, String ou ...@@ -155,25 +120,29 @@ Future<Null> saveDurationsHistogram(List<Map<String, dynamic>> events, String ou
/// Scrolls each demo menu item into view, launches it, then returns to the /// Scrolls each demo menu item into view, launches it, then returns to the
/// home screen twice. /// home screen twice.
Future<Null> runDemos(Iterable<Demo> demos, FlutterDriver driver) async { Future<Null> runDemos(List<String> demos, FlutterDriver driver) async {
for (Demo demo in demos) { for (String demo in demos) {
print('Testing "${demo.title}" demo'); print('Testing "$demo" demo');
final SerializableFinder menuItem = find.text(demo.title); final SerializableFinder menuItem = find.text(demo);
await driver.scrollIntoView(menuItem, alignment: 0.5); await driver.scrollUntilVisible(find.byType('CustomScrollView'), menuItem,
await new Future<Null>.delayed(kWaitBetweenActions); dyScroll: -48.0,
alignment: 0.5,
);
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(kWaitBetweenActions);
if (demo.synchronized) { // This demo's back button isn't initially visible.
await driver.tap(find.byTooltip('Back')); if (demo == 'Backdrop')
} else { await driver.tap(find.byTooltip('Tap to dismiss'));
if (kUnsynchronizedDemos.contains(demo)) {
await driver.runUnsynchronized<Future<Null>>(() async { await driver.runUnsynchronized<Future<Null>>(() async {
await new Future<Null>.delayed(kWaitBetweenActions);
await driver.tap(find.byTooltip('Back')); await driver.tap(find.byTooltip('Back'));
}); });
} else {
await driver.tap(find.byTooltip('Back'));
} }
await new Future<Null>.delayed(kWaitBetweenActions);
} }
print('Success'); print('Success');
} }
...@@ -184,10 +153,16 @@ void main([List<String> args = const <String>[]]) { ...@@ -184,10 +153,16 @@ void main([List<String> args = const <String>[]]) {
FlutterDriver driver; FlutterDriver driver;
setUpAll(() async { setUpAll(() async {
driver = await FlutterDriver.connect(); driver = await FlutterDriver.connect();
if (args.contains('--with_semantics')) { if (args.contains('--with_semantics')) {
print('Enabeling semantics...'); print('Enabeling semantics...');
await driver.setSemantics(true); await driver.setSemantics(true);
} }
// See _handleMessages() in transitions_perf.dart.
_allDemos = const JsonDecoder().convert(await driver.requestData('demoNames'));
if (_allDemos.isEmpty)
throw 'no demo names found';
}); });
tearDownAll(() async { tearDownAll(() async {
...@@ -197,14 +172,15 @@ void main([List<String> args = const <String>[]]) { ...@@ -197,14 +172,15 @@ void main([List<String> args = const <String>[]]) {
test('all demos', () async { test('all demos', () async {
// Collect timeline data for just a limited set of demos to avoid OOMs. // Collect timeline data for just a limited set of demos to avoid OOMs.
final Timeline timeline = await driver.traceAction(() async { final Timeline timeline = await driver.traceAction(
final Iterable<Demo> profiledDemos = demos.where((Demo demo) => demo.profiled); () async {
await runDemos(profiledDemos, driver); await runDemos(kProfiledDemos, driver);
}, },
streams: const <TimelineStream>[ streams: const <TimelineStream>[
TimelineStream.dart, TimelineStream.dart,
TimelineStream.embedder, TimelineStream.embedder,
]); ],
);
// Save the duration (in microseconds) of the first timeline Frame event // Save the duration (in microseconds) of the first timeline Frame event
// that follows a 'Start Transition' event. The Gallery app adds a // that follows a 'Start Transition' event. The Gallery app adds a
...@@ -214,9 +190,15 @@ void main([List<String> args = const <String>[]]) { ...@@ -214,9 +190,15 @@ void main([List<String> args = const <String>[]]) {
final String histogramPath = path.join(testOutputsDirectory, 'transition_durations.timeline.json'); final String histogramPath = path.join(testOutputsDirectory, 'transition_durations.timeline.json');
await saveDurationsHistogram(timeline.json['traceEvents'], histogramPath); await saveDurationsHistogram(timeline.json['traceEvents'], histogramPath);
// Scroll back to the top
await driver.scrollUntilVisible(find.byType('CustomScrollView'), find.text(_allDemos[0]),
dyScroll: 200.0,
alignment: 0.0
);
// Execute the remaining tests. // Execute the remaining tests.
final Iterable<Demo> unprofiledDemos = demos.where((Demo demo) => !demo.profiled); final Set<String> unprofiledDemos = new Set<String>.from(_allDemos)..removeAll(kProfiledDemos);
await runDemos(unprofiledDemos, driver); await runDemos(unprofiledDemos.toList(), driver);
}, timeout: const Timeout(const Duration(minutes: 5))); }, timeout: const Timeout(const Duration(minutes: 5)));
}); });
......
...@@ -409,10 +409,71 @@ class FlutterDriver { ...@@ -409,10 +409,71 @@ class FlutterDriver {
/// Scrolls the Scrollable ancestor of the widget located by [finder] /// Scrolls the Scrollable ancestor of the widget located by [finder]
/// until the widget is completely visible. /// until the widget is completely visible.
///
/// If the widget located by [finder] is contained by a scrolling widget
/// that lazily creates its children, like [ListView] or [CustomScrollView],
/// then this method may fail because [finder] doesn't actually exist.
/// The [scrollUntilVisible] method can be used in this case.
Future<Null> scrollIntoView(SerializableFinder finder, { double alignment: 0.0, Duration timeout }) async { Future<Null> scrollIntoView(SerializableFinder finder, { double alignment: 0.0, Duration timeout }) async {
return await _sendCommand(new ScrollIntoView(finder, alignment: alignment, timeout: timeout)).then((Map<String, dynamic> _) => null); return await _sendCommand(new ScrollIntoView(finder, alignment: alignment, timeout: timeout)).then((Map<String, dynamic> _) => null);
} }
/// Repeatedly [scroll] the widget located by [scrollable] by [dxScroll] and
/// [dyScroll] until [item] is visible, and then use [scrollIntoView] to
/// ensure the item's final position matches [alignment].
///
/// The [scrollable] must locate the scrolling widget that contains [item].
/// Typically `find.byType('ListView') or `find.byType('CustomScrollView')`.
///
/// Atleast one of [dxScroll] and [dyScroll] must be non-zero.
///
/// If [item] is below the currently visible items, then specify a negative
/// value for [dyScroll] that's a small enough increment to expose [item]
/// without potentially scrolling it up and completely out of view. Similarly
/// if [item] is above, then specify a positve value for [dyScroll].
///
/// If [item] is to the right of the the currently visible items, then
/// specify a negative value for [dxScroll] that's a small enough increment to
/// expose [item] without potentially scrolling it up and completely out of
/// view. Similarly if [item] is to the left, then specify a positve value
/// for [dyScroll].
///
/// The [timeout] value should be long enough to accommodate as many scrolls
/// as needed to bring an item into view. The default is 10 seconds.
Future<Null> scrollUntilVisible(SerializableFinder scrollable, SerializableFinder item, {
double alignment: 0.0,
double dxScroll: 0.0,
double dyScroll: 0.0,
Duration timeout: const Duration(seconds: 10),
}) async {
assert(scrollable != null);
assert(item != null);
assert(alignment != null);
assert(dxScroll != null);
assert(dyScroll != null);
assert(dxScroll != 0.0 || dyScroll != 0.0);
assert(timeout != null);
// If the item is already visible then we're done.
bool isVisible = false;
try {
await waitFor(item, timeout: const Duration(milliseconds: 100));
isVisible = true;
} on DriverError {
// Assume that that waitFor timed out because the item isn't visible.
}
if (!isVisible) {
waitFor(item, timeout: timeout).then((Null _) { isVisible = true; });
while (!isVisible) {
await scroll(scrollable, dxScroll, dyScroll, const Duration(milliseconds: 100));
await new Future<Null>.delayed(const Duration(milliseconds: 500));
}
}
return scrollIntoView(item, alignment: alignment);
}
/// Returns the text in the `Text` widget located by [finder]. /// Returns the text in the `Text` widget located by [finder].
Future<String> getText(SerializableFinder finder, { Duration timeout }) async { Future<String> getText(SerializableFinder finder, { Duration timeout }) async {
return GetTextResult.fromJson(await _sendCommand(new GetText(finder, timeout: timeout))).text; return GetTextResult.fromJson(await _sendCommand(new GetText(finder, timeout: timeout))).text;
......
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