diff --git a/dev/automated_tests/flutter_run_test/flutter_run_test.dart b/dev/automated_tests/flutter_run_test/flutter_run_test.dart new file mode 100644 index 0000000000..eb4f9461a1 --- /dev/null +++ b/dev/automated_tests/flutter_run_test/flutter_run_test.dart @@ -0,0 +1,19 @@ +// Copyright 2018 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 'package:flutter_test/flutter_test.dart'; + +void main() { + group('example', () { + test('passed', () { + expect(true, true); + }); + test('failed', () { + expect(true, false); + }); + test('skipped', () { + expect(true, false); + }, skip: true); + }); +} diff --git a/dev/devicelab/bin/tasks/flutter_run_test.dart b/dev/devicelab/bin/tasks/flutter_run_test.dart new file mode 100644 index 0000000000..bc76e627e8 --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_run_test.dart @@ -0,0 +1,67 @@ +// Copyright 2018 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 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'package:flutter_devicelab/framework/adb.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; + +final Directory flutterGalleryDir = dir(path.join(flutterDirectory.path, 'examples/hello_world')); +final File runTestSource = File(path.join( + flutterDirectory.path, 'dev', 'automated_tests', 'flutter_run_test', 'flutter_run_test.dart', +)); +const Pattern passedMessageMatch = '+0: example passed'; +const Pattern failedMessageMatch = '+1: example failed [E]'; +const Pattern skippedMessageMatch = '+1 -1: example skipped'; +const Pattern finishedMessageMatch = '+1 ~1 -1: Some tests failed.'; + +Future main() async { + deviceOperatingSystem = DeviceOperatingSystem.android; + await task(createFlutterRunTask); +} + +// verifies that the messages above are printed as a test script is executed. +Future createFlutterRunTask() async { + bool passedTest = false; + bool failedTest = false; + bool skippedTest = false; + bool finishedMessage = false; + final Device device = await devices.workingDevice; + await device.unlock(); + final List options = [ + '-t', runTestSource.absolute.path, '-d', device.deviceId, + ]; + setLocalEngineOptionIfNecessary(options); + await inDirectory(flutterGalleryDir, () async { + startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['run']..addAll(options), + environment: null + ); + final Completer finished = Completer(); + final StreamSubscription subscription = device.logcat.listen((String line) { + // tests execute in order. + if (line.contains(passedMessageMatch)) { + passedTest = true; + } else if (line.contains(failedMessageMatch)) { + failedTest = true; + } else if (line.contains(skippedMessageMatch)) { + skippedTest = true; + } else if (line.contains(finishedMessageMatch)) { + finishedMessage = true; + finished.complete(); + } + }); + await finished.future.timeout(const Duration(minutes: 1)); + subscription.cancel(); + }); + return passedTest && failedTest && skippedTest && finishedMessage + ? TaskResult.success({}) + : TaskResult.failure('Test did not execute as expected.'); +} + diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index 17567926f9..d4a3a56fb7 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -283,6 +283,13 @@ tasks: stage: devicelab required_agent_capabilities: ["linux/android"] + flutter_run_test: + description: > + Tests the `flutter run -t` command with a testfile. + stage: devicelab + required_agent_capabilities: ["linux/android"] + flaky: true + named_isolates_test: description: > Tests naming and attaching to specific isolates. diff --git a/packages/flutter_test/lib/flutter_test.dart b/packages/flutter_test/lib/flutter_test.dart index 12dc015519..61e439a184 100644 --- a/packages/flutter_test/lib/flutter_test.dart +++ b/packages/flutter_test/lib/flutter_test.dart @@ -56,6 +56,7 @@ export 'src/matchers.dart'; export 'src/nonconst.dart'; export 'src/stack_manipulation.dart'; export 'src/test_async_utils.dart'; +export 'src/test_compat.dart'; export 'src/test_exception_reporter.dart'; export 'src/test_pointer.dart'; export 'src/test_text_input.dart'; diff --git a/packages/flutter_test/lib/src/test_compat.dart b/packages/flutter_test/lib/src/test_compat.dart new file mode 100644 index 0000000000..7334274269 --- /dev/null +++ b/packages/flutter_test/lib/src/test_compat.dart @@ -0,0 +1,514 @@ +// Copyright 2018 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 'dart:async'; + +import 'package:test_api/src/backend/declarer.dart'; // ignore: implementation_imports +import 'package:test_api/src/frontend/timeout.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/group.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/group_entry.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/test.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/suite.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/live_test.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/suite_platform.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/message.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/invoker.dart'; // ignore: implementation_imports +import 'package:test_api/src/backend/state.dart'; // ignore: implementation_imports + +import 'package:test_api/test_api.dart'; + +Declarer _localDeclarer; +Declarer get _declarer { + final Declarer declarer = Zone.current[#test.declarer]; + if (declarer != null) { + return declarer; + } + // If no declarer is defined, this test is being run via `flutter run -t test_file.dart`. + if (_localDeclarer == null) { + _localDeclarer = Declarer(); + Future(() { + Invoker.guard>(() async { + final _Reporter reporter = _Reporter(color: false); // disable color when run directly. + final Group group = _declarer.build(); + final Suite suite = Suite(group, SuitePlatform(Runtime.vm)); + await _runGroup(suite, group, [], reporter); + reporter._onDone(); + }); + }); + } + return _localDeclarer; +} + +Future _runGroup(Suite suiteConfig, Group group, List parents, _Reporter reporter) async { + parents.add(group); + try { + final bool skipGroup = group.metadata.skip; + bool setUpAllSucceeded = true; + if (!skipGroup && group.setUpAll != null) { + final LiveTest liveTest = group.setUpAll.load(suiteConfig, groups: parents); + await _runLiveTest(suiteConfig, liveTest, reporter, countSuccess: false); + setUpAllSucceeded = liveTest.state.result.isPassing; + } + if (setUpAllSucceeded) { + for (GroupEntry entry in group.entries) { + if (entry is Group) { + await _runGroup(suiteConfig, entry, parents, reporter); + } else if (entry.metadata.skip) { + await _runSkippedTest(suiteConfig, entry, parents, reporter); + } else { + final Test test = entry; + await _runLiveTest(suiteConfig, test.load(suiteConfig, groups: parents), reporter); + } + } + } + // Even if we're closed or setUpAll failed, we want to run all the + // teardowns to ensure that any state is properly cleaned up. + if (!skipGroup && group.tearDownAll != null) { + final LiveTest liveTest = group.tearDownAll.load(suiteConfig, groups: parents); + await _runLiveTest(suiteConfig, liveTest, reporter, countSuccess: false); + } + } finally { + parents.remove(group); + } +} + +Future _runLiveTest(Suite suiteConfig, LiveTest liveTest, _Reporter reporter, {bool countSuccess = true}) async { + reporter._onTestStarted(liveTest); + // Schedule a microtask to ensure that [onTestStarted] fires before the + // first [LiveTest.onStateChange] event. + await Future.microtask(liveTest.run); + // Once the test finishes, use await null to do a coarse-grained event + // loop pump to avoid starving non-microtask events. + await null; + final bool isSuccess = liveTest.state.result.isPassing; + if (isSuccess) { + reporter.passed.add(liveTest); + } else { + reporter.failed.add(liveTest); + } +} + +Future _runSkippedTest(Suite suiteConfig, Test test, List parents, _Reporter reporter) async { + final LocalTest skipped = LocalTest(test.name, test.metadata, () {}, trace: test.trace); + if (skipped.metadata.skipReason != null) { + print('Skip: ${skipped.metadata.skipReason}'); + } + final LiveTest liveTest = skipped.load(suiteConfig); + reporter._onTestStarted(liveTest); + reporter.skipped.add(skipped); +} + +// TODO(nweiz): This and other top-level functions should throw exceptions if +// they're called after the declarer has finished declaring. +/// Creates a new test case with the given description (converted to a string) +/// and body. +/// +/// The description will be added to the descriptions of any surrounding +/// [group]s. If [testOn] is passed, it's parsed as a [platform selector][]; the +/// test will only be run on matching platforms. +/// +/// [platform selector]: https://github.com/dart-lang/test/tree/master/pkgs/test#platform-selectors +/// +/// If [timeout] is passed, it's used to modify or replace the default timeout +/// of 30 seconds. Timeout modifications take precedence in suite-group-test +/// order, so [timeout] will also modify any timeouts set on the group or suite. +/// +/// If [skip] is a String or `true`, the test is skipped. If it's a String, it +/// should explain why the test is skipped; this reason will be printed instead +/// of running the test. +/// +/// If [tags] is passed, it declares user-defined tags that are applied to the +/// test. These tags can be used to select or skip the test on the command line, +/// or to do bulk test configuration. All tags should be declared in the +/// [package configuration file][configuring tags]. The parameter can be an +/// [Iterable] of tag names, or a [String] representing a single tag. +/// +/// If [retry] is passed, the test will be retried the provided number of times +/// before being marked as a failure. +/// +/// [configuring tags]: https://github.com/dart-lang/test/blob/master/doc/package_config.md#configuring-tags +/// +/// [onPlatform] allows tests to be configured on a platform-by-platform +/// basis. It's a map from strings that are parsed as [PlatformSelector]s to +/// annotation classes: [Timeout], [Skip], or lists of those. These +/// annotations apply only on the given platforms. For example: +/// +/// test('potentially slow test', () { +/// // ... +/// }, onPlatform: { +/// // This test is especially slow on Windows. +/// 'windows': new Timeout.factor(2), +/// 'browser': [ +/// new Skip('TODO: add browser support'), +/// // This will be slow on browsers once it works on them. +/// new Timeout.factor(2) +/// ] +/// }); +/// +/// If multiple platforms match, the annotations apply in order as through +/// they were in nested groups. +/// +/// If the `solo` flag is `true`, only tests and groups marked as +/// 'solo' will be be run. This only restricts tests *within this test +/// suite*—tests in other suites will run as normal. We recommend that users +/// avoid this flag if possible and instead use the test runner flag `-n` to +/// filter tests by name. +void test(Object description, Function body, { + String testOn, + Timeout timeout, + dynamic skip, + dynamic tags, + Map onPlatform, + int retry, + }) { + _declarer.test( + description.toString(), body, + testOn: testOn, + timeout: timeout, + skip: skip, + onPlatform: onPlatform, + tags: tags, + retry: retry, + ); +} + +/// Creates a group of tests. +/// +/// A group's description (converted to a string) is included in the descriptions +/// of any tests or sub-groups it contains. [setUp] and [tearDown] are also scoped +/// to the containing group. +/// +/// If [testOn] is passed, it's parsed as a [platform selector][]; the test will +/// only be run on matching platforms. +/// +/// [platform selector]: https://github.com/dart-lang/test/tree/master/pkgs/test#platform-selectors +/// +/// If [timeout] is passed, it's used to modify or replace the default timeout +/// of 30 seconds. Timeout modifications take precedence in suite-group-test +/// order, so [timeout] will also modify any timeouts set on the suite, and will +/// be modified by any timeouts set on individual tests. +/// +/// If [skip] is a String or `true`, the group is skipped. If it's a String, it +/// should explain why the group is skipped; this reason will be printed instead +/// of running the group's tests. +/// +/// If [tags] is passed, it declares user-defined tags that are applied to the +/// test. These tags can be used to select or skip the test on the command line, +/// or to do bulk test configuration. All tags should be declared in the +/// [package configuration file][configuring tags]. The parameter can be an +/// [Iterable] of tag names, or a [String] representing a single tag. +/// +/// [configuring tags]: https://github.com/dart-lang/test/blob/master/doc/package_config.md#configuring-tags +/// +/// [onPlatform] allows groups to be configured on a platform-by-platform +/// basis. It's a map from strings that are parsed as [PlatformSelector]s to +/// annotation classes: [Timeout], [Skip], or lists of those. These +/// annotations apply only on the given platforms. For example: +/// +/// group('potentially slow tests', () { +/// // ... +/// }, onPlatform: { +/// // These tests are especially slow on Windows. +/// 'windows': new Timeout.factor(2), +/// 'browser': [ +/// new Skip('TODO: add browser support'), +/// // They'll be slow on browsers once it works on them. +/// new Timeout.factor(2) +/// ] +/// }); +/// +/// If multiple platforms match, the annotations apply in order as through +/// they were in nested groups. +/// +/// If the `solo` flag is `true`, only tests and groups marked as +/// 'solo' will be be run. This only restricts tests *within this test +/// suite*—tests in other suites will run as normal. We recommend that users +/// avoid this flag if possible, and instead use the test runner flag `-n` to +/// filter tests by name. +void group(Object description, Function body) { + _declarer.group(description.toString(), body); +} + +/// Registers a function to be run before tests. +/// +/// This function will be called before each test is run. [callback] may be +/// asynchronous; if so, it must return a [Future]. +/// +/// If this is called within a test group, it applies only to tests in that +/// group. [callback] will be run after any set-up callbacks in parent groups or +/// at the top level. +/// +/// Each callback at the top level or in a given group will be run in the order +/// they were declared. +void setUp(Function body) { + _declarer.setUp(body); +} + +/// Registers a function to be run after tests. +/// +/// This function will be called after each test is run. [callback] may be +/// asynchronous; if so, it must return a [Future]. +/// +/// If this is called within a test group, it applies only to tests in that +/// group. [callback] will be run before any tear-down callbacks in parent +/// groups or at the top level. +/// +/// Each callback at the top level or in a given group will be run in the +/// reverse of the order they were declared. +/// +/// See also [addTearDown], which adds tear-downs to a running test. +void tearDown(Function body) { + _declarer.tearDown(body); +} + +/// Registers a function to be run once before all tests. +/// +/// [callback] may be asynchronous; if so, it must return a [Future]. +/// +/// If this is called within a test group, [callback] will run before all tests +/// in that group. It will be run after any [setUpAll] callbacks in parent +/// groups or at the top level. It won't be run if none of the tests in the +/// group are run. +/// +/// **Note**: This function makes it very easy to accidentally introduce hidden +/// dependencies between tests that should be isolated. In general, you should +/// prefer [setUp], and only use [setUpAll] if the callback is prohibitively +/// slow. +void setUpAll(Function body) { + _declarer.setUpAll(body); +} + +/// Registers a function to be run once after all tests. +/// +/// If this is called within a test group, [callback] will run after all tests +/// in that group. It will be run before any [tearDownAll] callbacks in parent +/// groups or at the top level. It won't be run if none of the tests in the +/// group are run. +/// +/// **Note**: This function makes it very easy to accidentally introduce hidden +/// dependencies between tests that should be isolated. In general, you should +/// prefer [tearDown], and only use [tearDownAll] if the callback is +/// prohibitively slow. +void tearDownAll(Function body) { + _declarer.tearDownAll(body); +} + + +/// A reporter that prints each test on its own line. +/// +/// This is currently used in place of [CompactReporter] by `lib/test.dart`, +/// which can't transitively import `dart:io` but still needs access to a runner +/// so that test files can be run directly. This means that until issue 6943 is +/// fixed, this must not import `dart:io`. +class _Reporter { + _Reporter({bool color = true, bool printPath = true}) + : _printPath = printPath, + _green = color ? '\u001b[32m' : '', + _red = color ? '\u001b[31m' : '', + _yellow = color ? '\u001b[33m' : '', + _bold = color ? '\u001b[1m' : '', + _noColor = color ? '\u001b[0m' : ''; + final List passed = []; + final List failed = []; + final List skipped = []; + + /// The terminal escape for green text, or the empty string if this is Windows + /// or not outputting to a terminal. + final String _green; + + /// The terminal escape for red text, or the empty string if this is Windows + /// or not outputting to a terminal. + final String _red; + + /// The terminal escape for yellow text, or the empty string if this is + /// Windows or not outputting to a terminal. + final String _yellow; + + /// The terminal escape for bold text, or the empty string if this is + /// Windows or not outputting to a terminal. + final String _bold; + + /// The terminal escape for removing test coloring, or the empty string if + /// this is Windows or not outputting to a terminal. + final String _noColor; + + /// Whether the path to each test's suite should be printed. + final bool _printPath; + + /// A stopwatch that tracks the duration of the full run. + final Stopwatch _stopwatch = Stopwatch(); + + /// The size of `_engine.passed` last time a progress notification was + /// printed. + int _lastProgressPassed; + + /// The size of `_engine.skipped` last time a progress notification was + /// printed. + int _lastProgressSkipped; + + /// The size of `_engine.failed` last time a progress notification was + /// printed. + int _lastProgressFailed; + + /// The message printed for the last progress notification. + String _lastProgressMessage; + + /// The suffix added to the last progress notification. + String _lastProgressSuffix; + + /// The set of all subscriptions to various streams. + final Set> _subscriptions = Set>(); + + /// A callback called when the engine begins running [liveTest]. + void _onTestStarted(LiveTest liveTest) { + if (!_stopwatch.isRunning) { + _stopwatch.start(); + } + _progressLine(_description(liveTest)); + _subscriptions.add(liveTest.onStateChange.listen((State state) => _onStateChange(liveTest, state))); + _subscriptions.add(liveTest.onError.listen((AsyncError error) => _onError(liveTest, error.error, error.stackTrace))); + _subscriptions.add(liveTest.onMessage.listen((Message message) { + _progressLine(_description(liveTest)); + String text = message.text; + if (message.type == MessageType.skip) { + text = ' $_yellow$text$_noColor'; + } + print(text); + })); + } + + /// A callback called when [liveTest]'s state becomes [state]. + void _onStateChange(LiveTest liveTest, State state) { + if (state.status != Status.complete) { + return; + } + } + + /// A callback called when [liveTest] throws [error]. + void _onError(LiveTest liveTest, Object error, StackTrace stackTrace) { + if (liveTest.state.status != Status.complete) { + return; + } + _progressLine(_description(liveTest), suffix: ' $_bold$_red[E]$_noColor'); + print(_indent(error.toString())); + print(_indent('$stackTrace')); + } + + /// A callback called when the engine is finished running tests. + /// + /// [success] will be `true` if all tests passed, `false` if some tests + /// failed, and `null` if the engine was closed prematurely. + void _onDone() { + final bool success = failed.isEmpty; + if (success == null) { + return; + } + if (!success) { + _progressLine('Some tests failed.', color: _red); + } else if (passed.isEmpty) { + _progressLine('All tests skipped.'); + } else { + _progressLine('All tests passed!'); + } + } + + /// Prints a line representing the current state of the tests. + /// + /// [message] goes after the progress report. If [color] is passed, it's used + /// as the color for [message]. If [suffix] is passed, it's added to the end + /// of [message]. + void _progressLine(String message, {String color, String suffix}) { + // Print nothing if nothing has changed since the last progress line. + if (passed.length == _lastProgressPassed && + skipped.length == _lastProgressSkipped && + failed.length == _lastProgressFailed && + message == _lastProgressMessage && + // Don't re-print just because a suffix was removed. + (suffix == null || suffix == _lastProgressSuffix)) { + return; + } + _lastProgressPassed = passed.length; + _lastProgressSkipped = skipped.length; + _lastProgressFailed = failed.length; + _lastProgressMessage = message; + _lastProgressSuffix = suffix; + + if (suffix != null) { + message += suffix; + } + color ??= ''; + final Duration duration = _stopwatch.elapsed; + final StringBuffer buffer = StringBuffer(); + + // \r moves back to the beginning of the current line. + buffer.write('${_timeString(duration)} '); + buffer.write(_green); + buffer.write('+'); + buffer.write(passed.length); + buffer.write(_noColor); + + if (skipped.isNotEmpty) { + buffer.write(_yellow); + buffer.write(' ~'); + buffer.write(skipped.length); + buffer.write(_noColor); + } + + if (failed.isNotEmpty) { + buffer.write(_red); + buffer.write(' -'); + buffer.write(failed.length); + buffer.write(_noColor); + } + + buffer.write(': '); + buffer.write(color); + buffer.write(message); + buffer.write(_noColor); + + print(buffer.toString()); + } + + /// Returns a representation of [duration] as `MM:SS`. + String _timeString(Duration duration) { + final String minutes = duration.inMinutes.toString().padLeft(2, '0'); + final String seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + /// Returns a description of [liveTest]. + /// + /// This differs from the test's own description in that it may also include + /// the suite's name. + String _description(LiveTest liveTest) { + String name = liveTest.test.name; + if (_printPath && liveTest.suite.path != null) { + name = '${liveTest.suite.path}: $name'; + } + return name; + } +} + +String _indent(String string, {int size, String first}) { + size ??= first == null ? 2 : first.length; + return _prefixLines(string, ' ' * size, first: first); +} + +String _prefixLines(String text, String prefix, {String first, String last, String single}) { + first ??= prefix; + last ??= prefix; + single ??= first ?? last ?? prefix; + final List lines = text.split('\n'); + if (lines.length == 1) { + return '$single$text'; + } + final StringBuffer buffer = StringBuffer('$first${lines.first}\n'); + // Write out all but the first and last lines with [prefix]. + for (String line in lines.skip(1).take(lines.length - 2)) { + buffer.writeln('$prefix$line'); + } + buffer.write('$last${lines.last}'); + return buffer.toString(); +} diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index ef584aa027..b50ab03417 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -19,12 +19,22 @@ import 'controller.dart'; import 'finders.dart'; import 'matchers.dart'; import 'test_async_utils.dart'; +import 'test_compat.dart'; import 'test_text_input.dart'; /// Keep users from needing multiple imports to test semantics. export 'package:flutter/rendering.dart' show SemanticsHandle; +/// Hide these imports so that they do not conflict with our own implementations in +/// test_compat.dart. This handles setting up a declarer when one is not defined, which +/// can happen when a test is executed via flutter_run. export 'package:test_api/test_api.dart' hide + test, + group, + setUpAll, + tearDownAll, + setUp, + tearDown, expect, // we have our own wrapper below TypeMatcher, // matcher's TypeMatcher conflicts with the one in the Flutter framework isInstanceOf; // we have our own wrapper in matchers.dart @@ -63,7 +73,7 @@ void testWidgets(String description, WidgetTesterCallback callback, { final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); final WidgetTester tester = WidgetTester._(binding); timeout ??= binding.defaultTestTimeout; - test_package.test( + test( description, () { tester._recordNumberOfSemanticsHandles();