diff --git a/tools/engine_tool/lib/src/commands/build_command.dart b/tools/engine_tool/lib/src/commands/build_command.dart index c623a47166dc6..15613220151ae 100644 --- a/tools/engine_tool/lib/src/commands/build_command.dart +++ b/tools/engine_tool/lib/src/commands/build_command.dart @@ -5,10 +5,9 @@ import 'package:engine_build_configs/engine_build_configs.dart'; import '../build_utils.dart'; - +import '../logger.dart'; import 'command.dart'; - -const String _configFlag = 'config'; +import 'flags.dart'; // TODO(johnmccutchan): Should BuildConfig be BuilderConfig and GlobalBuild be BuildConfig? // TODO(johnmccutchan): List all available build targets and allow the user @@ -25,7 +24,7 @@ final class BuildCommand extends CommandBase { builds = runnableBuilds(environment, configs); // Add options here that are common to all queries. argParser.addOption( - _configFlag, + configFlag, abbr: 'c', defaultsTo: 'host_debug', help: 'Specify the build config to use', @@ -51,7 +50,7 @@ final class BuildCommand extends CommandBase { @override Future run() async { - final String configName = argResults![_configFlag] as String; + final String configName = argResults![configFlag] as String; final GlobalBuild? build = builds .where((GlobalBuild build) => build.name == configName) .firstOrNull; @@ -60,28 +59,38 @@ final class BuildCommand extends CommandBase { return 1; } final GlobalBuildRunner buildRunner = GlobalBuildRunner( - platform: environment.platform, - processRunner: environment.processRunner, - abi: environment.abi, - engineSrcDir: environment.engine.srcDir, - build: build); + platform: environment.platform, + processRunner: environment.processRunner, + abi: environment.abi, + engineSrcDir: environment.engine.srcDir, + build: build, + runTests: false, + ); + + Spinner? spinner; void handler(RunnerEvent event) { switch (event) { case RunnerStart(): - environment.logger.info('$event: ${event.command.join(' ')}'); + environment.logger.status('$event ', newline: false); + spinner = environment.logger.startSpinner(); case RunnerProgress(done: true): + spinner?.finish(); + spinner = null; environment.logger.clearLine(); environment.logger.status(event); - case RunnerProgress(done: false): - { - final String percent = '${event.percent.toStringAsFixed(1)}%'; - final String fraction = '(${event.completed}/${event.total})'; - final String prefix = '[${event.name}] $percent $fraction '; - final String what = event.what; - environment.logger.clearLine(); - environment.logger.status('$prefix$what'); - } + case RunnerProgress(done: false): { + spinner?.finish(); + spinner = null; + final String percent = '${event.percent.toStringAsFixed(1)}%'; + final String fraction = '(${event.completed}/${event.total})'; + final String prefix = '[${event.name}] $percent $fraction '; + final String what = event.what; + environment.logger.clearLine(); + environment.logger.status('$prefix$what', newline: false, fit: true); + } default: + spinner?.finish(); + spinner = null; environment.logger.status(event); } } diff --git a/tools/engine_tool/lib/src/commands/flags.dart b/tools/engine_tool/lib/src/commands/flags.dart index d08514b174797..c31d18f73d3e8 100644 --- a/tools/engine_tool/lib/src/commands/flags.dart +++ b/tools/engine_tool/lib/src/commands/flags.dart @@ -13,6 +13,8 @@ // Keep this list alphabetized. const String allFlag = 'all'; const String builderFlag = 'builder'; +const String configFlag = 'config'; const String dryRunFlag = 'dry-run'; const String quietFlag = 'quiet'; +const String runTestsFlag = 'run-tests'; const String verboseFlag = 'verbose'; diff --git a/tools/engine_tool/lib/src/logger.dart b/tools/engine_tool/lib/src/logger.dart index 3cb7c60a7e110..cbcb34499f183 100644 --- a/tools/engine_tool/lib/src/logger.dart +++ b/tools/engine_tool/lib/src/logger.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async' show runZoned; +import 'dart:async' show Timer, runZoned; import 'dart:io' as io show IOSink, stderr, @@ -29,7 +29,7 @@ import 'package:meta/meta.dart'; /// which can be inspected by unit tetss. class Logger { /// Constructs a logger for use in the tool. - Logger() : _logger = log.Logger.detached('et') { + Logger() : _logger = log.Logger.detached('et'), _test = false { _logger.level = statusLevel; _logger.onRecord.listen(_handler); _setupIoSink(io.stderr); @@ -38,7 +38,7 @@ class Logger { /// A logger for tests. @visibleForTesting - Logger.test() : _logger = log.Logger.detached('et') { + Logger.test() : _logger = log.Logger.detached('et'), _test = true { _logger.level = statusLevel; _logger.onRecord.listen((log.LogRecord r) => _testLogs.add(r)); } @@ -94,6 +94,9 @@ class Logger { final log.Logger _logger; final List _testLogs = []; + final bool _test; + + Spinner? _status; /// Get the current logging level. log.Level get level => _logger.level; @@ -104,39 +107,135 @@ class Logger { } /// Record a log message at level [Logger.error]. - void error(Object? message, {int indent = 0, bool newline = true}) { - _emitLog(errorLevel, message, indent, newline); + void error( + Object? message, { + int indent = 0, + bool newline = true, + bool fit = false, + }) { + _emitLog(errorLevel, message, indent, newline, fit); } /// Record a log message at level [Logger.warning]. - void warning(Object? message, {int indent = 0, bool newline = true}) { - _emitLog(warningLevel, message, indent, newline); + void warning( + Object? message, { + int indent = 0, + bool newline = true, + bool fit = false, + }) { + _emitLog(warningLevel, message, indent, newline, fit); } /// Record a log message at level [Logger.warning]. - void status(Object? message, {int indent = 0, bool newline = true}) { - _emitLog(statusLevel, message, indent, newline); + void status( + Object? message, { + int indent = 0, + bool newline = true, + bool fit = false, + }) { + _emitLog(statusLevel, message, indent, newline, fit); } /// Record a log message at level [Logger.info]. - void info(Object? message, {int indent = 0, bool newline = true}) { - _emitLog(infoLevel, message, indent, newline); + void info( + Object? message, { + int indent = 0, + bool newline = true, + bool fit = false, + }) { + _emitLog(infoLevel, message, indent, newline, fit); } /// Writes a number of spaces to stdout equal to the width of the terminal /// and emits a carriage return. void clearLine() { - if (!io.stdout.hasTerminal) { + if (!io.stdout.hasTerminal || _test) { + return; + } + _status?.pause(); + _emitClearLine(); + _status?.resume(); + } + + /// Starts printing a progress spinner. + Spinner startSpinner({ + void Function()? onFinish, + }) { + void finishCallback() { + onFinish?.call(); + _status = null; + } + _status = io.stdout.hasTerminal && !_test + ? FlutterSpinner(onFinish: finishCallback) + : Spinner(onFinish: finishCallback); + _status!.start(); + return _status!; + } + + static void _emitClearLine() { + if (io.stdout.supportsAnsiEscapes) { + // Go to start of the line and clear the line. + _ioSinkWrite(io.stdout, '\r\x1B[K'); return; } final int width = io.stdout.terminalColumns; + final String backspaces = '\b' * width; final String spaces = ' ' * width; - _ioSinkWrite(io.stdout, '$spaces\r'); + _ioSinkWrite(io.stdout, '$backspaces$spaces$backspaces'); } - void _emitLog(log.Level level, Object? message, int indent, bool newline) { - final String m = '${' ' * indent}$message${newline ? '\n' : ''}'; + void _emitLog( + log.Level level, + Object? message, + int indent, + bool newline, + bool fit, + ) { + String m = '${' ' * indent}$message${newline ? '\n' : ''}'; + if (fit && io.stdout.hasTerminal) { + m = fitToWidth(m, io.stdout.terminalColumns); + } + _status?.pause(); _logger.log(level, m); + _status?.resume(); + } + + /// Shorten a string such that its length will be `w` by replacing + /// enough characters in the middle with '...'. Trailing whitespace will not + /// be preserved or counted against 'w', but if the input ends with a newline, + /// then the output will end with a newline that is not counted against 'w'. + /// That is, if the input string ends with a newline, the output string will + /// have length up to (w + 1) and end with a newline. + /// + /// If w <= 0, the result will be the empty string. + /// If w <= 3, the result will be a string containing w '.'s. + /// If there are a different number of non-'...' characters to the right and + /// left of '...' in the result, then the right will have one more than the + /// left. + @visibleForTesting + static String fitToWidth(String s, int w) { + // Preserve a trailing newline if needed. + final String maybeNewline = s.endsWith('\n') ? '\n' : ''; + if (w <= 0) { + return maybeNewline; + } + if (w <= 3) { + return '${'.' * w}$maybeNewline'; + } + + // But remove trailing whitespace before removing the middle of the string. + s = s.trimRight(); + if (s.length <= w) { + return '$s$maybeNewline'; + } + + // remove (s.length + 3 - w) characters from the middle of `s` and + // replace them with '...'. + final int diff = (s.length + 3) - w; + final int leftEnd = (s.length - diff) ~/ 2; + final int rightStart = (s.length + diff) ~/ 2; + s = s.replaceRange(leftEnd, rightStart, '...'); + return s + maybeNewline; } /// In a [Logger] constructed by [Logger.test], this list will contain all of @@ -144,3 +243,98 @@ class Logger { @visibleForTesting List get testLogs => _testLogs; } + + +/// A base class for progress spinners, and a no-op implementation that prints +/// nothing. +class Spinner { + /// Creates a progress spinner. If supplied the `onDone` callback will be + /// called when `finish()` is called. + Spinner({ + this.onFinish, + }); + + /// The callback called when `finish()` is called. + final void Function()? onFinish; + + /// Starts the spinner animation. + void start() {} + + /// Pauses the spinner animation. That is, this call causes printing to the + /// terminal to stop. + void pause() {} + + /// Resumes the animation at the same from where `pause()` was called. + void resume() {} + + /// Ends an animation, calling the `onFinish` callback if one was provided. + void finish() { + onFinish?.call(); + } +} + +/// A [Spinner] implementation that prints an animated "Flutter" banner. +class FlutterSpinner extends Spinner { + // ignore: public_member_api_docs + FlutterSpinner({ + super.onFinish, + }); + + @visibleForTesting + /// The frames of the animation. + static const String frames = '⢸⡯⠭⠅⢸⣇⣀⡀⢸⣇⣸⡇⠈⢹⡏⠁⠈⢹⡏⠁⢸⣯⣭⡅⢸⡯⢕⡂⠀⠀'; + + static final List _flutterAnimation = frames + .runes + .map((int scalar) => String.fromCharCode(scalar)) + .toList(); + + Timer? _timer; + int _ticks = 0; + int _lastAnimationFrameLength = 0; + + @override + void start() { + _startSpinner(); + } + + void _startSpinner() { + _timer = Timer.periodic(const Duration(milliseconds: 100), _callback); + _callback(_timer!); + } + + void _callback(Timer timer) { + Logger._ioSinkWrite(io.stdout, '\b' * _lastAnimationFrameLength); + _ticks += 1; + final String newFrame = _currentAnimationFrame; + _lastAnimationFrameLength = newFrame.runes.length; + Logger._ioSinkWrite(io.stdout, newFrame); + } + + String get _currentAnimationFrame { + return _flutterAnimation[_ticks % _flutterAnimation.length]; + } + + @override + void pause() { + Logger._emitClearLine(); + _lastAnimationFrameLength = 0; + _timer?.cancel(); + } + + @override + void resume() { + _startSpinner(); + } + + @override + void finish() { + _timer?.cancel(); + _timer = null; + Logger._emitClearLine(); + _lastAnimationFrameLength = 0; + if (onFinish != null) { + onFinish!(); + } + } +} diff --git a/tools/engine_tool/test/build_command_test.dart b/tools/engine_tool/test/build_command_test.dart index c34e8549915d3..b55f7d88698fe 100644 --- a/tools/engine_tool/test/build_command_test.dart +++ b/tools/engine_tool/test/build_command_test.dart @@ -119,4 +119,39 @@ void main() { expect(runHistory[1].length, greaterThanOrEqualTo(1)); expect(runHistory[1][0], contains('ninja')); }); + + test('build command invokes generator', () async { + final Logger logger = Logger.test(); + final (Environment env, List> runHistory) = linuxEnv(logger); + final ToolCommandRunner runner = ToolCommandRunner( + environment: env, + configs: configs, + ); + final int result = await runner.run([ + 'build', + '--config', + 'build_name', + ]); + expect(result, equals(0)); + expect(runHistory.length, greaterThanOrEqualTo(3)); + expect(runHistory[2].length, greaterThanOrEqualTo(2)); + expect(runHistory[2][0], contains('python3')); + expect(runHistory[2][1], contains('gen/script.py')); + }); + + test('build command does not invoke tests', () async { + final Logger logger = Logger.test(); + final (Environment env, List> runHistory) = linuxEnv(logger); + final ToolCommandRunner runner = ToolCommandRunner( + environment: env, + configs: configs, + ); + final int result = await runner.run([ + 'build', + '--config', + 'build_name', + ]); + expect(result, equals(0)); + expect(runHistory.length, lessThanOrEqualTo(3)); + }); } diff --git a/tools/engine_tool/test/logger_test.dart b/tools/engine_tool/test/logger_test.dart index 5170f4d7cd077..86e739dcb7a03 100644 --- a/tools/engine_tool/test/logger_test.dart +++ b/tools/engine_tool/test/logger_test.dart @@ -78,4 +78,48 @@ void main() { logger.info('info', newline: false); expect(stringsFromLogs(logger.testLogs), equals(['info'])); }); + + test('fitToWidth', () { + expect(Logger.fitToWidth('hello', 0), equals('')); + expect(Logger.fitToWidth('hello', 1), equals('.')); + expect(Logger.fitToWidth('hello', 2), equals('..')); + expect(Logger.fitToWidth('hello', 3), equals('...')); + expect(Logger.fitToWidth('hello', 4), equals('...o')); + expect(Logger.fitToWidth('hello', 5), equals('hello')); + + expect(Logger.fitToWidth('foobar', 5), equals('f...r')); + + expect(Logger.fitToWidth('foobarb', 5), equals('f...b')); + expect(Logger.fitToWidth('foobarb', 6), equals('f...rb')); + + expect(Logger.fitToWidth('foobarba', 5), equals('f...a')); + expect(Logger.fitToWidth('foobarba', 6), equals('f...ba')); + expect(Logger.fitToWidth('foobarba', 7), equals('fo...ba')); + + expect(Logger.fitToWidth('hello\n', 0), equals('\n')); + expect(Logger.fitToWidth('hello\n', 1), equals('.\n')); + expect(Logger.fitToWidth('hello\n', 2), equals('..\n')); + expect(Logger.fitToWidth('hello\n', 3), equals('...\n')); + expect(Logger.fitToWidth('hello\n', 4), equals('...o\n')); + expect(Logger.fitToWidth('hello\n', 5), equals('hello\n')); + + expect(Logger.fitToWidth('foobar\n', 5), equals('f...r\n')); + + expect(Logger.fitToWidth('foobarb\n', 5), equals('f...b\n')); + expect(Logger.fitToWidth('foobarb\n', 6), equals('f...rb\n')); + + expect(Logger.fitToWidth('foobarba\n', 5), equals('f...a\n')); + expect(Logger.fitToWidth('foobarba\n', 6), equals('f...ba\n')); + expect(Logger.fitToWidth('foobarba\n', 7), equals('fo...ba\n')); + }); + + test('Spinner calls onFinish callback', () { + final Logger logger = Logger.test(); + bool called = false; + final Spinner spinner = logger.startSpinner( + onFinish: () { called = true; }, + ); + spinner.finish(); + expect(called, isTrue); + }); }