diff --git a/lib/web_ui/dev/README.md b/lib/web_ui/dev/README.md index 309ca9a28c80c..83259de8885fa 100644 --- a/lib/web_ui/dev/README.md +++ b/lib/web_ui/dev/README.md @@ -7,7 +7,7 @@ `felt` supports multiple commands as follows: 1. **`felt check-licenses`**: Checks that all Dart and JS source code files contain the correct license headers. -2. **`felt test`**: Runs all or some tests depending on the passed arguments. +2. **`felt test`**: Runs all or some tests depending on the passed arguments. It supports a watch mode for convenience. 3. **`felt build`**: Builds the engine locally so it can be used by Flutter apps. It also supports a watch mode for more convenience. You could also run `felt help` or `felt help ` to get more information about the available commands and arguments. @@ -43,6 +43,18 @@ To run all tests on Chrome. This will run both integration tests and the unit te felt test ``` +To run a specific test: + +``` +felt test test/engine/util_test.dart +``` + +To enable watch mode so that the test re-runs on every change: + +``` +felt test --watch test/engine/util_test.dart +``` + To run unit tests only: ``` diff --git a/lib/web_ui/dev/build.dart b/lib/web_ui/dev/build.dart index 5a23dd45f86cf..fe7cc2c894a7d 100644 --- a/lib/web_ui/dev/build.dart +++ b/lib/web_ui/dev/build.dart @@ -6,12 +6,11 @@ import 'dart:async'; import 'package:args/command_runner.dart'; -import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; -import 'package:watcher/watcher.dart'; import 'environment.dart'; import 'utils.dart'; +import 'watcher.dart'; class BuildCommand extends Command with ArgUtils { BuildCommand() { @@ -37,7 +36,7 @@ class BuildCommand extends Command with ArgUtils { final FilePath libPath = FilePath.fromWebUi('lib'); final Pipeline buildPipeline = Pipeline(steps: [ gn, - () => ninja(), + ninja, ]); await buildPipeline.start(); @@ -77,124 +76,3 @@ Future ninja() { environment.hostDebugUnoptDir.path, ]); } - -enum PipelineStatus { - idle, - started, - stopping, - stopped, - error, - done, -} - -typedef PipelineStep = Future Function(); - -class Pipeline { - Pipeline({@required this.steps}); - - final Iterable steps; - - Future _currentStepFuture; - - PipelineStatus status = PipelineStatus.idle; - - Future start() async { - status = PipelineStatus.started; - try { - for (PipelineStep step in steps) { - if (status != PipelineStatus.started) { - break; - } - _currentStepFuture = step(); - await _currentStepFuture; - } - status = PipelineStatus.done; - } catch (error, stackTrace) { - status = PipelineStatus.error; - print('Error in the pipeline: $error'); - print(stackTrace); - } finally { - _currentStepFuture = null; - } - } - - Future stop() { - status = PipelineStatus.stopping; - return (_currentStepFuture ?? Future.value(null)).then((_) { - status = PipelineStatus.stopped; - }); - } -} - -typedef WatchEventPredicate = bool Function(WatchEvent event); - -class PipelineWatcher { - PipelineWatcher({ - @required this.dir, - @required this.pipeline, - this.ignore, - }) : watcher = DirectoryWatcher(dir); - - /// The path of the directory to watch for changes. - final String dir; - - /// The pipeline to be executed when an event is fired by the watcher. - final Pipeline pipeline; - - /// Used to watch a directory for any file system changes. - final DirectoryWatcher watcher; - - /// A callback that determines whether to rerun the pipeline or not for a - /// given [WatchEvent] instance. - final WatchEventPredicate ignore; - - void start() { - watcher.events.listen(_onEvent); - } - - int _pipelineRunCount = 0; - Timer _scheduledPipeline; - - void _onEvent(WatchEvent event) { - if (ignore != null && ignore(event)) { - return; - } - - final String relativePath = path.relative(event.path, from: dir); - print('- [${event.type}] ${relativePath}'); - - _pipelineRunCount++; - _scheduledPipeline?.cancel(); - _scheduledPipeline = Timer(const Duration(milliseconds: 100), () { - _scheduledPipeline = null; - _runPipeline(); - }); - } - - void _runPipeline() { - int runCount; - switch (pipeline.status) { - case PipelineStatus.started: - pipeline.stop().then((_) { - runCount = _pipelineRunCount; - pipeline.start().then((_) => _pipelineDone(runCount)); - }); - break; - - case PipelineStatus.stopping: - // We are already trying to stop the pipeline. No need to do anything. - break; - - default: - runCount = _pipelineRunCount; - pipeline.start().then((_) => _pipelineDone(runCount)); - break; - } - } - - void _pipelineDone(int pipelineRunCount) { - if (pipelineRunCount == _pipelineRunCount) { - print('*** Done! ***'); - } - } -} diff --git a/lib/web_ui/dev/test_runner.dart b/lib/web_ui/dev/test_runner.dart index 8c261151bddf4..40ef747579f72 100644 --- a/lib/web_ui/dev/test_runner.dart +++ b/lib/web_ui/dev/test_runner.dart @@ -26,6 +26,7 @@ import 'safari_installation.dart'; import 'supported_browsers.dart'; import 'test_platform.dart'; import 'utils.dart'; +import 'watcher.dart'; /// The type of tests requested by the tool user. enum TestTypesRequested { @@ -48,6 +49,12 @@ class TestCommand extends Command with ArgUtils { 'opportunity to add breakpoints or inspect loaded code before ' 'running the code.', ) + ..addFlag( + 'watch', + abbr: 'w', + help: 'Run in watch mode so the tests re-run whenever a change is ' + 'made.', + ) ..addFlag( 'unit-tests-only', defaultsTo: false, @@ -100,6 +107,8 @@ class TestCommand extends Command with ArgUtils { @override final String description = 'Run tests.'; + bool get isWatchMode => boolArg('watch'); + TestTypesRequested testTypesRequested = null; /// How many dart2js build tasks are running at the same time. @@ -146,25 +155,80 @@ class TestCommand extends Command with ArgUtils { await macOsInfo.printInformation(); } - switch (testTypesRequested) { - case TestTypesRequested.unit: - return runUnitTests(); - case TestTypesRequested.integration: - return runIntegrationTests(); - case TestTypesRequested.all: - if (runAllTests && isIntegrationTestsAvailable) { - bool unitTestResult = await runUnitTests(); - bool integrationTestResult = await runIntegrationTests(); - if (integrationTestResult != unitTestResult) { - print('Tests run. Integration tests passed: $integrationTestResult ' - 'unit tests passed: $unitTestResult'); + final Pipeline testPipeline = Pipeline(steps: [ + () async => clearTerminalScreen(), + () => runTestsOfType(testTypesRequested), + ]); + await testPipeline.start(); + + if (isWatchMode) { + final FilePath dir = FilePath.fromWebUi(''); + print(''); + print('Initial test run is done!'); + print('Watching ${dir.relativeToCwd}/lib and ${dir.relativeToCwd}/test to re-run tests'); + print(''); + PipelineWatcher( + dir: dir.absolute, + pipeline: testPipeline, + ignore: (event) { + // Ignore font files that are copied whenever tests run. + if (event.path.endsWith('.ttf')) { + return true; + } + + // Ignore auto-generated JS files. + // The reason we are using `.contains()` instead of `.endsWith()` is + // because the auto-generated files could end with any of the + // following: + // + // - browser_test.dart.js + // - browser_test.dart.js.map + // - browser_test.dart.js.deps + if (event.path.contains('browser_test.dart.js')) { + return true; } - return integrationTestResult && unitTestResult; - } else { - return await runUnitTests(); + + // React to changes in lib/ and test/ folders. + final String relativePath = path.relative(event.path, from: dir.absolute); + if (relativePath.startsWith('lib/') || relativePath.startsWith('test/')) { + return false; + } + + // Ignore anything else. + return true; } + ).start(); + // Return a never-ending future. + return Completer().future; + } else { + return true; + } + } + + Future runTestsOfType(TestTypesRequested testTypesRequested) async { + try { + switch (testTypesRequested) { + case TestTypesRequested.unit: + return runUnitTests(); + case TestTypesRequested.integration: + return runIntegrationTests(); + case TestTypesRequested.all: + if (runAllTests && isIntegrationTestsAvailable) { + bool unitTestResult = await runUnitTests(); + bool integrationTestResult = await runIntegrationTests(); + if (integrationTestResult != unitTestResult) { + print('Tests run. Integration tests passed: $integrationTestResult ' + 'unit tests passed: $unitTestResult'); + } + return integrationTestResult && unitTestResult; + } else { + return await runUnitTests(); + } + } + throw UnimplementedError('Unknown test type requested: $testTypesRequested'); + } on TestFailureException { + return true; } - return false; } Future runIntegrationTests() async { @@ -499,7 +563,12 @@ class TestCommand extends Command with ArgUtils { void _checkExitCode() { if (io.exitCode != 0) { - throw ToolException('Process exited with exit code ${io.exitCode}.'); + if (isWatchMode) { + io.exitCode = 0; + throw TestFailureException(); + } else { + throw ToolException('Process exited with exit code ${io.exitCode}.'); + } } } @@ -729,3 +798,5 @@ class TestBuildInput { TestBuildInput(this.path, {this.forCanvasKit = false}); } + +class TestFailureException implements Exception {} diff --git a/lib/web_ui/dev/utils.dart b/lib/web_ui/dev/utils.dart index 2549dd2cd2812..1dc9676e899bb 100644 --- a/lib/web_ui/dev/utils.dart +++ b/lib/web_ui/dev/utils.dart @@ -14,6 +14,16 @@ import 'package:path/path.dart' as path; import 'environment.dart'; import 'exceptions.dart'; +/// Clears the terminal screen and places the cursor at the top left corner. +/// +/// This works on Linux and Mac. On Windows, it's a no-op. +void clearTerminalScreen() { + if (!io.Platform.isWindows) { + // See: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences + print("\x1B[2J\x1B[1;2H"); + } +} + class FilePath { FilePath.fromCwd(String relativePath) : _absolutePath = path.absolute(relativePath); diff --git a/lib/web_ui/dev/watcher.dart b/lib/web_ui/dev/watcher.dart new file mode 100644 index 0000000000000..10acb2ad9561d --- /dev/null +++ b/lib/web_ui/dev/watcher.dart @@ -0,0 +1,149 @@ +// Copyright 2013 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.6 +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; +import 'package:watcher/watcher.dart'; + +enum PipelineStatus { + idle, + started, + stopping, + stopped, + error, + done, +} + +typedef PipelineStep = Future Function(); + +/// Represents a sequence of asynchronous tasks to be executed. +/// +/// The pipeline can be executed by calling [start] and stopped by calling +/// [stop]. +/// +/// When a pipeline is stopped, it switches to the [PipelineStatus.stopping] +/// state and waits until the current task finishes. +class Pipeline { + Pipeline({@required this.steps}); + + final Iterable steps; + + Future _currentStepFuture; + + PipelineStatus status = PipelineStatus.idle; + + /// Starts executing tasks of the pipeline. + Future start() async { + status = PipelineStatus.started; + try { + for (PipelineStep step in steps) { + if (status != PipelineStatus.started) { + break; + } + _currentStepFuture = step(); + await _currentStepFuture; + } + status = PipelineStatus.done; + } catch (error, stackTrace) { + status = PipelineStatus.error; + print('Error in the pipeline: $error'); + print(stackTrace); + } finally { + _currentStepFuture = null; + } + } + + /// Stops executing any more tasks in the pipeline. + /// + /// If a task is already being executed, it won't be interrupted. + Future stop() { + status = PipelineStatus.stopping; + return (_currentStepFuture ?? Future.value(null)).then((_) { + status = PipelineStatus.stopped; + }); + } +} + +/// Signature of functions to be called when a [WatchEvent] is received. +typedef WatchEventPredicate = bool Function(WatchEvent event); + +/// Responsible for watching a directory [dir] and executing the given +/// [pipeline] whenever a change occurs in the directory. +/// +/// The [ignore] callback can be used to customize the watching behavior to +/// ignore certain files. +class PipelineWatcher { + PipelineWatcher({ + @required this.dir, + @required this.pipeline, + this.ignore, + }) : watcher = DirectoryWatcher(dir); + + /// The path of the directory to watch for changes. + final String dir; + + /// The pipeline to be executed when an event is fired by the watcher. + final Pipeline pipeline; + + /// Used to watch a directory for any file system changes. + final DirectoryWatcher watcher; + + /// A callback that determines whether to rerun the pipeline or not for a + /// given [WatchEvent] instance. + final WatchEventPredicate ignore; + + /// Activates the watcher. + void start() { + watcher.events.listen(_onEvent); + } + + int _pipelineRunCount = 0; + Timer _scheduledPipeline; + + void _onEvent(WatchEvent event) { + if (ignore != null && ignore(event)) { + return; + } + + final String relativePath = path.relative(event.path, from: dir); + print('- [${event.type}] ${relativePath}'); + + _pipelineRunCount++; + _scheduledPipeline?.cancel(); + _scheduledPipeline = Timer(const Duration(milliseconds: 100), () { + _scheduledPipeline = null; + _runPipeline(); + }); + } + + void _runPipeline() { + int runCount; + switch (pipeline.status) { + case PipelineStatus.started: + pipeline.stop().then((_) { + runCount = _pipelineRunCount; + pipeline.start().then((_) => _pipelineDone(runCount)); + }); + break; + + case PipelineStatus.stopping: + // We are already trying to stop the pipeline. No need to do anything. + break; + + default: + runCount = _pipelineRunCount; + pipeline.start().then((_) => _pipelineDone(runCount)); + break; + } + } + + void _pipelineDone(int pipelineRunCount) { + if (pipelineRunCount == _pipelineRunCount) { + print('*** Done! ***'); + } + } +}