| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // @dart = 2.9 |
| |
| import 'dart:convert' show JsonEncoder, json; |
| |
| import 'package:file/file.dart'; |
| import 'package:file/local.dart'; |
| import 'package:flutter_driver/flutter_driver.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:test/test.dart' hide TypeMatcher, isInstanceOf; |
| |
| import 'package:flutter_gallery/demo_lists.dart'; |
| |
| const FileSystem _fs = LocalFileSystem(); |
| |
| const List<String> kSkippedDemos = <String>[]; |
| |
| // All of the gallery demos, identified as "title@category". |
| // |
| // 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 |
| /// it into a histogram, and saves to a JSON file. |
| Future<void> saveDurationsHistogram(List<Map<String, dynamic>> events, String outputPath) async { |
| final Map<String, List<int>> durations = <String, List<int>>{}; |
| Map<String, dynamic> startEvent; |
| int frameStart; |
| |
| // Save the duration of the first frame after each 'Start Transition' event. |
| for (final Map<String, dynamic> event in events) { |
| final String eventName = event['name'] as String; |
| if (eventName == 'Start Transition') { |
| assert(startEvent == null); |
| startEvent = event; |
| } else if (startEvent != null && eventName == 'Frame') { |
| final String phase = event['ph'] as String; |
| final int timestamp = event['ts'] as int; |
| if (phase == 'B') { |
| assert(frameStart == null); |
| frameStart = timestamp; |
| } else { |
| assert(phase == 'E'); |
| final String routeName = startEvent['args']['to'] as String; |
| durations[routeName] ??= <int>[]; |
| durations[routeName].add(timestamp - frameStart); |
| startEvent = null; |
| frameStart = null; |
| } |
| } |
| } |
| |
| // Verify that the durations data is valid. |
| if (durations.keys.isEmpty) |
| throw 'no "Start Transition" timeline events found'; |
| final Map<String, int> unexpectedValueCounts = <String, int>{}; |
| durations.forEach((String routeName, List<int> values) { |
| if (values.length != 2) { |
| unexpectedValueCounts[routeName] = values.length; |
| } |
| }); |
| |
| if (unexpectedValueCounts.isNotEmpty) { |
| final StringBuffer error = StringBuffer('Some routes recorded wrong number of values (expected 2 values/route):\n\n'); |
| // When run with --trace-startup, the VM stores trace events in an endless buffer instead of a ring buffer. |
| error.write('You must add the --trace-startup parameter to run the test. \n\n'); |
| unexpectedValueCounts.forEach((String routeName, int count) { |
| error.writeln(' - $routeName recorded $count values.'); |
| }); |
| error.writeln('\nFull event sequence:'); |
| final Iterator<Map<String, dynamic>> eventIter = events.iterator; |
| String lastEventName = ''; |
| String lastRouteName = ''; |
| while (eventIter.moveNext()) { |
| final String eventName = eventIter.current['name'] as String; |
| |
| if (!<String>['Start Transition', 'Frame'].contains(eventName)) |
| continue; |
| |
| final String routeName = eventName == 'Start Transition' |
| ? eventIter.current['args']['to'] as String |
| : ''; |
| |
| if (eventName == lastEventName && routeName == lastRouteName) { |
| error.write('.'); |
| } else { |
| error.write('\n - $eventName $routeName .'); |
| } |
| |
| lastEventName = eventName; |
| lastRouteName = routeName; |
| } |
| throw error; |
| } |
| |
| // Save the durations Map to a file. |
| final File file = await _fs.file(outputPath).create(recursive: true); |
| await file.writeAsString(const JsonEncoder.withIndent(' ').convert(durations)); |
| } |
| |
| /// Scrolls each demo menu item into view, launches it, then returns to the |
| /// home screen twice. |
| Future<void> runDemos(List<String> demos, FlutterDriver driver) async { |
| final SerializableFinder demoList = find.byValueKey('GalleryDemoList'); |
| String currentDemoCategory; |
| |
| for (final String demo in demos) { |
| if (kSkippedDemos.contains(demo)) |
| continue; |
| |
| final String demoName = demo.substring(0, demo.indexOf('@')); |
| final String demoCategory = demo.substring(demo.indexOf('@') + 1); |
| print('> $demo'); |
| |
| if (currentDemoCategory == null) { |
| await driver.tap(find.text(demoCategory)); |
| } else if (currentDemoCategory != demoCategory) { |
| await driver.tap(find.byTooltip('Back')); |
| await driver.tap(find.text(demoCategory)); |
| // Scroll back to the top |
| await driver.scroll(demoList, 0.0, 10000.0, const Duration(milliseconds: 100)); |
| } |
| currentDemoCategory = demoCategory; |
| |
| final SerializableFinder demoItem = find.text(demoName); |
| await driver.scrollUntilVisible(demoList, demoItem, |
| dyScroll: -48.0, |
| alignment: 0.5, |
| timeout: const Duration(seconds: 30), |
| ); |
| |
| for (int i = 0; i < 2; i += 1) { |
| await driver.tap(demoItem); // Launch the demo |
| |
| if (kUnsynchronizedDemos.contains(demo)) { |
| await driver.runUnsynchronized<void>(() async { |
| await driver.tap(find.pageBack()); |
| }); |
| } else { |
| await driver.tap(find.pageBack()); |
| } |
| } |
| |
| print('< Success'); |
| } |
| |
| // Return to the home screen |
| await driver.tap(find.byTooltip('Back')); |
| } |
| |
| void main([List<String> args = const <String>[]]) { |
| final bool withSemantics = args.contains('--with_semantics'); |
| final bool hybrid = args.contains('--hybrid'); |
| group('flutter gallery transitions', () { |
| FlutterDriver driver; |
| setUpAll(() async { |
| driver = await FlutterDriver.connect(); |
| |
| // Wait for the first frame to be rasterized. |
| await driver.waitUntilFirstFrameRasterized(); |
| if (withSemantics) { |
| print('Enabeling semantics...'); |
| await driver.setSemantics(true); |
| } |
| |
| // See _handleMessages() in transitions_perf.dart. |
| _allDemos = List<String>.from(json.decode(await driver.requestData('demoNames')) as List<dynamic>); |
| if (_allDemos.isEmpty) |
| throw 'no demo names found'; |
| }); |
| |
| tearDownAll(() async { |
| if (driver != null) |
| await driver.close(); |
| }); |
| |
| test('find.bySemanticsLabel', () async { |
| // Assert that we can use semantics related finders in profile mode. |
| final int id = await driver.getSemanticsId(find.bySemanticsLabel('Material')); |
| expect(id, greaterThan(-1)); |
| }, skip: !withSemantics); |
| |
| test('all demos', () async { |
| // Collect timeline data for just a limited set of demos to avoid OOMs. |
| final Timeline timeline = await driver.traceAction( |
| () async { |
| if (hybrid) { |
| await driver.requestData('profileDemos'); |
| } else { |
| await runDemos(kProfiledDemos, driver); |
| } |
| }, |
| streams: const <TimelineStream>[ |
| TimelineStream.dart, |
| TimelineStream.embedder, |
| ], |
| ); |
| |
| // Save the duration (in microseconds) of the first timeline Frame event |
| // that follows a 'Start Transition' event. The Gallery app adds a |
| // 'Start Transition' event when a demo is launched (see GalleryItem). |
| final TimelineSummary summary = TimelineSummary.summarize(timeline); |
| await summary.writeSummaryToFile('transitions', pretty: true); |
| await summary.writeTimelineToFile('transitions', pretty: true); |
| final String histogramPath = path.join(testOutputsDirectory, 'transition_durations.timeline.json'); |
| await saveDurationsHistogram( |
| List<Map<String, dynamic>>.from(timeline.json['traceEvents'] as List<dynamic>), |
| histogramPath); |
| |
| // Execute the remaining tests. |
| if (hybrid) { |
| await driver.requestData('restDemos'); |
| } else { |
| final Set<String> unprofiledDemos = Set<String>.from(_allDemos)..removeAll(kProfiledDemos); |
| await runDemos(unprofiledDemos.toList(), driver); |
| } |
| |
| }, timeout: const Timeout(Duration(minutes: 5))); |
| }); |
| } |