diff --git a/examples/hello_world/hello_world.iml b/examples/hello_world/hello_world.iml index 9d5dae19540..c82504f3177 100644 --- a/examples/hello_world/hello_world.iml +++ b/examples/hello_world/hello_world.iml @@ -7,6 +7,7 @@ + diff --git a/packages/flutter_tools/bin/fuchsia_tester.dart b/packages/flutter_tools/bin/fuchsia_tester.dart index fb4ef775973..d4bd938eece 100644 --- a/packages/flutter_tools/bin/fuchsia_tester.dart +++ b/packages/flutter_tools/bin/fuchsia_tester.dart @@ -88,7 +88,6 @@ Future run(List args) async { } loader.installHook( shellPath: shellPath, - debuggerMode: false, ); PackageMap.globalPackagesPath = diff --git a/packages/flutter_tools/flutter_tools.iml b/packages/flutter_tools/flutter_tools.iml index 367b72461cd..22fe5c309c0 100644 --- a/packages/flutter_tools/flutter_tools.iml +++ b/packages/flutter_tools/flutter_tools.iml @@ -7,6 +7,12 @@ + + + + + + @@ -30,16 +36,10 @@ + - - - - - - - - + diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 9b3c208c384..380641270d1 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -83,7 +83,7 @@ Future main(List args) async { new RunCommand(verboseHelp: verboseHelp), new ScreenshotCommand(), new StopCommand(), - new TestCommand(), + new TestCommand(verboseHelp: verboseHelp), new TraceCommand(), new UpdatePackagesCommand(hidden: !verboseHelp), new UpgradeCommand(), diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart index e3285010a7b..6171abcc88b 100644 --- a/packages/flutter_tools/lib/src/commands/test.dart +++ b/packages/flutter_tools/lib/src/commands/test.dart @@ -4,9 +4,6 @@ import 'dart:async'; -import 'package:test/src/executable.dart' as test; // ignore: implementation_imports - -import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; @@ -14,16 +11,16 @@ import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; import '../base/process_manager.dart'; -import '../base/terminal.dart'; import '../cache.dart'; -import '../dart/package_map.dart'; import '../globals.dart'; import '../runner/flutter_command.dart'; import '../test/coverage_collector.dart'; -import '../test/flutter_platform.dart' as loader; +import '../test/event_printer.dart'; +import '../test/runner.dart'; +import '../test/watcher.dart'; class TestCommand extends FlutterCommand { - TestCommand() { + TestCommand({ bool verboseHelp: false }) { usesPubOption(); argParser.addFlag('start-paused', defaultsTo: false, @@ -53,6 +50,11 @@ class TestCommand extends FlutterCommand { defaultsTo: 'coverage/lcov.info', help: 'Where to store coverage information (if coverage is enabled).' ); + argParser.addFlag('machine', + hide: !verboseHelp, + negatable: false, + help: 'Handle machine structured JSON command input\n' + 'and provide output and progress in machine friendly format.'); commandValidator = () { if (!fs.isFileSync('pubspec.yaml')) { throwToolExit( @@ -70,37 +72,12 @@ class TestCommand extends FlutterCommand { @override String get description => 'Run Flutter unit tests for the current project.'; - Iterable _findTests(Directory directory) { - return directory.listSync(recursive: true, followLinks: false) - .where((FileSystemEntity entity) => entity.path.endsWith('_test.dart') && - fs.isFileSync(entity.path)) - .map((FileSystemEntity entity) => fs.path.absolute(entity.path)); - } - Directory get _currentPackageTestDir { // We don't scan the entire package, only the test/ subdirectory, so that // files with names like like "hit_test.dart" don't get run. return fs.directory('test'); } - Future _runTests(List testArgs, Directory testDirectory) async { - final Directory currentDirectory = fs.currentDirectory; - try { - if (testDirectory != null) { - printTrace('switching to directory $testDirectory to run tests'); - PackageMap.globalPackagesPath = fs.path.normalize(fs.path.absolute(PackageMap.globalPackagesPath)); - fs.currentDirectory = testDirectory; - } - printTrace('running test package with arguments: $testArgs'); - await test.main(testArgs); - // test.main() sets dart:io's exitCode global. - printTrace('test package returned with exit code $exitCode'); - return exitCode; - } finally { - fs.currentDirectory = currentDirectory; - } - } - Future _collectCoverageData(CoverageCollector collector, { bool mergeCoverageData: false }) async { final Status status = logger.startProgress('Collecting coverage information...'); final String coverageData = await collector.finalizeCoverage( @@ -161,7 +138,7 @@ class TestCommand extends FlutterCommand { } @override - Future runCommand() async { + Future runCommand() async { if (platform.isWindows) { throwToolExit( 'The test command is currently not supported on Windows: ' @@ -169,57 +146,57 @@ class TestCommand extends FlutterCommand { ); } - final List testArgs = []; - commandValidator(); - if (!terminal.supportsColor) - testArgs.addAll(['--no-color', '-rexpanded']); + Iterable files = argResults.rest.map((String testPath) => fs.path.absolute(testPath)).toList(); + + final bool startPaused = argResults['start-paused']; + if (startPaused && files.length != 1) { + throwToolExit( + 'When using --start-paused, you must specify a single test file to run.', + exitCode: 1); + } + + Directory workDir; + if (files.isEmpty) { + workDir = _currentPackageTestDir; + if (!workDir.existsSync()) + throwToolExit('Test directory "${workDir.path}" not found.'); + files = _findTests(workDir); + if (files.isEmpty) { + throwToolExit( + 'Test directory "${workDir.path}" does not appear to contain any test files.\n' + 'Test files must be in that directory and end with the pattern "_test.dart".' + ); + } + } CoverageCollector collector; if (argResults['coverage'] || argResults['merge-coverage']) { collector = new CoverageCollector(); - testArgs.add('--concurrency=1'); } - testArgs.add('--'); - - Directory testDir; - Iterable files = argResults.rest.map((String testPath) => fs.path.absolute(testPath)).toList(); - if (argResults['start-paused']) { - if (files.length != 1) - throwToolExit('When using --start-paused, you must specify a single test file to run.', exitCode: 1); - } else if (files.isEmpty) { - testDir = _currentPackageTestDir; - if (!testDir.existsSync()) - throwToolExit('Test directory "${testDir.path}" not found.'); - files = _findTests(testDir); - if (files.isEmpty) { - throwToolExit( - 'Test directory "${testDir.path}" does not appear to contain any test files.\n' - 'Test files must be in that directory and end with the pattern "_test.dart".' - ); - } + final bool wantEvents = argResults['machine']; + if (collector != null && wantEvents) { + throwToolExit( + "The test command doesn't support --machine and coverage together"); } - testArgs.addAll(files); - final InternetAddressType serverType = argResults['ipv6'] - ? InternetAddressType.IP_V6 - : InternetAddressType.IP_V4; - - final String shellPath = artifacts.getArtifactPath(Artifact.flutterTester); - if (!fs.isFileSync(shellPath)) - throwToolExit('Cannot find Flutter shell at $shellPath'); - loader.installHook( - shellPath: shellPath, - collector: collector, - debuggerMode: argResults['start-paused'], - serverType: serverType, - ); + TestWatcher watcher; + if (collector != null) { + watcher = collector; + } else if (wantEvents) { + watcher = new EventPrinter(); + } Cache.releaseLockEarly(); - final int result = await _runTests(testArgs, testDir); + final int result = await runTests(files, + workDir: workDir, + watcher: watcher, + enableObservatory: collector != null || startPaused, + startPaused: startPaused, + ipv6: argResults['ipv6']); if (collector != null) { if (!await _collectCoverageData(collector, mergeCoverageData: argResults['merge-coverage'])) @@ -228,5 +205,13 @@ class TestCommand extends FlutterCommand { if (result != 0) throwToolExit(null); + return const FlutterCommandResult(ExitStatus.success); } } + +Iterable _findTests(Directory directory) { + return directory.listSync(recursive: true, followLinks: false) + .where((FileSystemEntity entity) => entity.path.endsWith('_test.dart') && + fs.isFileSync(entity.path)) + .map((FileSystemEntity entity) => fs.path.absolute(entity.path)); +} diff --git a/packages/flutter_tools/lib/src/test/coverage_collector.dart b/packages/flutter_tools/lib/src/test/coverage_collector.dart index 3b7ded4a501..21b80e0befd 100644 --- a/packages/flutter_tools/lib/src/test/coverage_collector.dart +++ b/packages/flutter_tools/lib/src/test/coverage_collector.dart @@ -11,10 +11,18 @@ import '../base/io.dart'; import '../dart/package_map.dart'; import '../globals.dart'; +import 'watcher.dart'; + /// A class that's used to collect coverage data during tests. -class CoverageCollector { +class CoverageCollector extends TestWatcher { Map _globalHitmap; + @override + Future onFinishedTests(ProcessEvent event) async { + printTrace('test ${event.childIndex}: collecting coverage'); + await collectCoverage(event.process, event.observatoryUri); + } + void _addHitmap(Map hitmap) { if (_globalHitmap == null) _globalHitmap = hitmap; diff --git a/packages/flutter_tools/lib/src/test/event_printer.dart b/packages/flutter_tools/lib/src/test/event_printer.dart new file mode 100644 index 00000000000..4be6b77fe1f --- /dev/null +++ b/packages/flutter_tools/lib/src/test/event_printer.dart @@ -0,0 +1,41 @@ +// 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 'dart:convert' show JSON; + +import '../base/io.dart' show stdout; +import 'watcher.dart'; + +/// Prints JSON events when running a test in --machine mode. +class EventPrinter extends TestWatcher { + EventPrinter({StringSink out}) : this._out = out == null ? stdout: out; + + final StringSink _out; + + @override + void onStartedProcess(ProcessEvent event) { + _sendEvent("test.startedProcess", + {"observatoryUri": event.observatoryUri.toString()}); + } + + void _sendEvent(String name, [dynamic params]) { + final Map map = { 'event': name}; + if (params != null) { + map['params'] = params; + } + _send(map); + } + + void _send(Map command) { + final String encoded = JSON.encode(command, toEncodable: _jsonEncodeObject); + _out.writeln('\n[$encoded]'); + } + + dynamic _jsonEncodeObject(dynamic object) { + if (object is Uri) { + return object.toString(); + } + return object; + } +} diff --git a/packages/flutter_tools/lib/src/test/flutter_platform.dart b/packages/flutter_tools/lib/src/test/flutter_platform.dart index 58de703b7ce..8af0147f391 100644 --- a/packages/flutter_tools/lib/src/test/flutter_platform.dart +++ b/packages/flutter_tools/lib/src/test/flutter_platform.dart @@ -19,6 +19,7 @@ import '../base/process_manager.dart'; import '../dart/package_map.dart'; import '../globals.dart'; import 'coverage_collector.dart'; +import 'watcher.dart'; /// The timeout we give the test process to connect to the test harness /// once the process has entered its main method. @@ -53,17 +54,22 @@ final Map _kHosts = [TestPlatform.vm], () => new _FlutterPlatform( shellPath: shellPath, - collector: collector, - debuggerMode: debuggerMode, + watcher: watcher, + enableObservatory: enableObservatory, + startPaused: startPaused, explicitObservatoryPort: observatoryPort, explicitDiagnosticPort: diagnosticPort, host: _kHosts[serverType], @@ -78,8 +84,9 @@ typedef Future _Finalizer(); class _FlutterPlatform extends PlatformPlugin { _FlutterPlatform({ @required this.shellPath, - this.collector, - this.debuggerMode, + this.watcher, + this.enableObservatory, + this.startPaused, this.explicitObservatoryPort, this.explicitDiagnosticPort, this.host, @@ -88,8 +95,9 @@ class _FlutterPlatform extends PlatformPlugin { } final String shellPath; - final CoverageCollector collector; - final bool debuggerMode; + final TestWatcher watcher; + final bool enableObservatory; + final bool startPaused; final int explicitObservatoryPort; final int explicitDiagnosticPort; final InternetAddress host; @@ -105,7 +113,7 @@ class _FlutterPlatform extends PlatformPlugin { @override StreamChannel loadChannel(String testPath, TestPlatform platform) { - if (explicitObservatoryPort != null || explicitDiagnosticPort != null || debuggerMode) { + if (enableObservatory || explicitDiagnosticPort != null) { if (_testCount > 0) throwToolExit('installHook() was called with an observatory port, a diagnostic port, both, or debugger mode enabled, but then more than one test suite was run.'); } @@ -190,8 +198,8 @@ class _FlutterPlatform extends PlatformPlugin { shellPath, listenerFile.path, packages: PackageMap.globalPackagesPath, - enableObservatory: collector != null || debuggerMode, - startPaused: debuggerMode, + enableObservatory: enableObservatory, + startPaused: startPaused, observatoryPort: explicitObservatoryPort, diagnosticPort: explicitDiagnosticPort, ); @@ -216,19 +224,23 @@ class _FlutterPlatform extends PlatformPlugin { // Pipe stdout and stderr from the subprocess to our printStatus console. // We also keep track of what observatory port the engine used, if any. Uri processObservatoryUri; + _pipeStandardStreamsToConsole( process, reportObservatoryUri: (Uri detectedUri) { assert(processObservatoryUri == null); assert(explicitObservatoryPort == null || explicitObservatoryPort == detectedUri.port); - if (debuggerMode) { + if (startPaused) { printStatus('The test process has been started.'); printStatus('You can now connect to it using observatory. To connect, load the following Web site in your browser:'); printStatus(' $detectedUri'); printStatus('You should first set appropriate breakpoints, then resume the test in the debugger.'); } else { - printTrace('test $ourTestCount: using observatory uri $detectedUri from pid ${process.pid} to collect coverage'); + printTrace('test $ourTestCount: using observatory uri $detectedUri from pid ${process.pid}'); + } + if (watcher != null) { + watcher.onStartedProcess(new ProcessEvent(ourTestCount, process, detectedUri)); } processObservatoryUri = detectedUri; }, @@ -341,9 +353,9 @@ class _FlutterPlatform extends PlatformPlugin { break; } - if (subprocessActive && collector != null) { - printTrace('test $ourTestCount: collecting coverage'); - await collector.collectCoverage(process, processObservatoryUri); + if (subprocessActive && watcher != null) { + await watcher.onFinishedTests( + new ProcessEvent(ourTestCount, process, processObservatoryUri)); } } catch (error, stack) { printTrace('test $ourTestCount: error caught during test; ${controllerSinkClosed ? "reporting to console" : "sending to test framework"}'); diff --git a/packages/flutter_tools/lib/src/test/runner.dart b/packages/flutter_tools/lib/src/test/runner.dart new file mode 100644 index 00000000000..cd7ad43ec84 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/runner.dart @@ -0,0 +1,81 @@ +// 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 'dart:async'; + +// ignore: implementation_imports +import 'package:test/src/executable.dart' as test; + +import '../artifacts.dart'; +import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/terminal.dart'; +import '../dart/package_map.dart'; +import '../globals.dart'; +import '../test/flutter_platform.dart' as loader; +import 'watcher.dart'; + +/// Runs tests using package:test and the Flutter engine. +Future runTests( + List testFiles, { + Directory workDir, + bool enableObservatory: false, + bool startPaused: false, + bool ipv6: false, + TestWatcher watcher, + }) async { + // Compute the command-line arguments for package:test. + final List testArgs = []; + if (!terminal.supportsColor) + testArgs.addAll(['--no-color', '-rexpanded']); + + if (enableObservatory) { + testArgs.add('--concurrency=1'); + } + + testArgs.add('--'); + testArgs.addAll(testFiles); + + // Configure package:test to use the Flutter engine for child processes. + final String shellPath = artifacts.getArtifactPath(Artifact.flutterTester); + if (!fs.isFileSync(shellPath)) + throwToolExit('Cannot find Flutter shell at $shellPath'); + + final InternetAddressType serverType = + ipv6 ? InternetAddressType.IP_V6 : InternetAddressType.IP_V4; + + loader.installHook( + shellPath: shellPath, + watcher: watcher, + enableObservatory: enableObservatory, + startPaused: startPaused, + serverType: serverType, + ); + + // Set the package path used for child processes. + // TODO(skybrian): why is this global? Move to installHook? + PackageMap.globalPackagesPath = + fs.path.normalize(fs.path.absolute(PackageMap.globalPackagesPath)); + + // Call package:test's main method in the appropriate directory. + final Directory saved = fs.currentDirectory; + try { + if (workDir != null) { + printTrace('switching to directory $workDir to run tests'); + fs.currentDirectory = workDir; + } + + printTrace('running test package with arguments: $testArgs'); + await test.main(testArgs); + + // test.main() sets dart:io's exitCode global. + // TODO(skybrian): restore previous value? + printTrace('test package returned with exit code $exitCode'); + + return exitCode; + } finally { + fs.currentDirectory = saved; + } +} diff --git a/packages/flutter_tools/lib/src/test/watcher.dart b/packages/flutter_tools/lib/src/test/watcher.dart new file mode 100644 index 00000000000..114f456151d --- /dev/null +++ b/packages/flutter_tools/lib/src/test/watcher.dart @@ -0,0 +1,39 @@ +// 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 'dart:async'; +import '../base/io.dart' show Process; + +/// Callbacks for reporting progress while running tests. +class TestWatcher { + + /// Called after a child process starts. + /// + /// If startPaused was true, the caller needs to resume in Observatory to + /// start running the tests. + void onStartedProcess(ProcessEvent event) {} + + /// Called after the tests finish but before the process exits. + /// + /// The child process won't exit until this method completes. + /// Not called if the process died. + Future onFinishedTests(ProcessEvent event) async {} +} + +/// Describes a child process started during testing. +class ProcessEvent { + ProcessEvent(this.childIndex, this.process, this.observatoryUri); + + /// The index assigned when the child process was launched. + /// + /// Indexes are assigned consecutively starting from zero. + /// When debugging, there should only be one child process so this will + /// always be zero. + final int childIndex; + + final Process process; + + /// The observatory Uri or null if not debugging. + final Uri observatoryUri; +}