diff --git a/.github/workflows/mason_logger.yaml b/.github/workflows/mason_logger.yaml index e6accde4d..cfb6c1431 100644 --- a/.github/workflows/mason_logger.yaml +++ b/.github/workflows/mason_logger.yaml @@ -36,6 +36,18 @@ jobs: - name: Analyze run: dart analyze --fatal-infos --fatal-warnings . + - name: Verify CI Behavior + run: | + dart test/ci.dart >> ci.txt + actual="ci.txt" + expected="test/fixtures/ci.txt" + if cmp -s "$actual" "$expected"; then + echo "PASSED" + else + echo "FAILED" + exit 1 + fi + - name: Run Tests run: | dart pub global activate coverage 1.2.0 diff --git a/packages/mason_logger/lib/src/mason_logger.dart b/packages/mason_logger/lib/src/mason_logger.dart index 70d1ccbef..e489e91b9 100644 --- a/packages/mason_logger/lib/src/mason_logger.dart +++ b/packages/mason_logger/lib/src/mason_logger.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:mason_logger/mason_logger.dart'; +import 'package:mason_logger/src/stdio_overrides.dart'; part 'progress.dart'; diff --git a/packages/mason_logger/lib/src/progress.dart b/packages/mason_logger/lib/src/progress.dart index fda929742..1af5309ab 100644 --- a/packages/mason_logger/lib/src/progress.dart +++ b/packages/mason_logger/lib/src/progress.dart @@ -51,6 +51,19 @@ class Progress { _stopwatch ..reset() ..start(); + + final stdioType = StdioOverrides.current?.stdioType ?? io.stdioType; + + // The animation is only shown when it would be meaningful. + // Do not animate if the stdio type is not a terminal. + if (stdioType(_stdout) != io.StdioType.terminal) { + final frames = _options.animation.frames; + final char = frames.isEmpty ? '' : frames.first; + final prefix = char.isEmpty ? char : '${lightGreen.wrap(char)} '; + _write('$prefix$_message...'); + return; + } + _timer = Timer.periodic(const Duration(milliseconds: 80), _onTick); } @@ -62,7 +75,7 @@ class Progress { final Stopwatch _stopwatch; - late final Timer _timer; + Timer? _timer; String _message; @@ -74,26 +87,26 @@ class Progress { _write( '''$_clearLine${lightGreen.wrap('✓')} ${update ?? _message} $_time\n''', ); - _timer.cancel(); + _timer?.cancel(); } /// End the progress and mark it as failed. void fail([String? update]) { - _timer.cancel(); + _timer?.cancel(); _write('$_clearLine${red.wrap('✗')} ${update ?? _message} $_time\n'); _stopwatch.stop(); } /// Update the progress message. void update(String update) { - _write(_clearLine); + if (_timer != null) _write(_clearLine); _message = update; _onTick(_timer); } /// Cancel the progress and remove the written line. void cancel() { - _timer.cancel(); + _timer?.cancel(); _write(_clearLine); _stopwatch.stop(); } @@ -103,7 +116,7 @@ class Progress { '\r'; // bring cursor to the start of the current line } - void _onTick(Timer _) { + void _onTick(Timer? _) { _index++; final frames = _options.animation.frames; final char = frames.isEmpty ? '' : frames[_index % frames.length]; diff --git a/packages/mason_logger/lib/src/stdio_overrides.dart b/packages/mason_logger/lib/src/stdio_overrides.dart new file mode 100644 index 000000000..c9e90f3c4 --- /dev/null +++ b/packages/mason_logger/lib/src/stdio_overrides.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'dart:io' as io; + +const _asyncRunZoned = runZoned; + +/// This class facilitates overriding [io.stdioType]. +abstract class StdioOverrides { + static final _token = Object(); + + /// Returns the current [StdioOverrides] instance. + /// + /// This will return `null` if the current [Zone] does not contain + /// any [StdioOverrides]. + /// + /// See also: + /// * [StdioOverrides.runZoned] to provide [StdioOverrides] + /// in a fresh [Zone]. + /// + static StdioOverrides? get current { + return Zone.current[_token] as StdioOverrides?; + } + + /// Runs [body] in a fresh [Zone] using the provided overrides. + static R runZoned( + R Function() body, { + io.StdioType Function(dynamic object) Function()? stdioType, + }) { + final overrides = _StdioOverridesScope(stdioType); + return _asyncRunZoned( + body, + zoneValues: {_token: overrides}, + ); + } + + /// The [io.stdioType] that will be used for errors within the current [Zone]. + io.StdioType Function(dynamic object) get stdioType => io.stdioType; +} + +class _StdioOverridesScope extends StdioOverrides { + _StdioOverridesScope(this._stdioType); + + final StdioOverrides? _previous = StdioOverrides.current; + final io.StdioType Function(dynamic object) Function()? _stdioType; + + @override + io.StdioType Function(dynamic object) get stdioType { + return _stdioType?.call() ?? _previous?.stdioType ?? super.stdioType; + } +} diff --git a/packages/mason_logger/test/ci.dart b/packages/mason_logger/test/ci.dart new file mode 100644 index 000000000..a47d7eb2e --- /dev/null +++ b/packages/mason_logger/test/ci.dart @@ -0,0 +1,12 @@ +import 'package:mason_logger/mason_logger.dart'; + +Future main() async { + final logger = Logger(); + final progress = logger.progress('Calculating'); + await Future.delayed(const Duration(seconds: 1)); + progress.update('This is taking longer than expected'); + await Future.delayed(const Duration(seconds: 1)); + progress.update('Almost done'); + await Future.delayed(const Duration(seconds: 1)); + progress.complete('Done'); +} diff --git a/packages/mason_logger/test/fixtures/ci.txt b/packages/mason_logger/test/fixtures/ci.txt new file mode 100644 index 000000000..8bb00d2c0 --- /dev/null +++ b/packages/mason_logger/test/fixtures/ci.txt @@ -0,0 +1 @@ +⠋ Calculating... ⠙ This is taking longer than expected... (1.0s) ⠹ Almost done... (2.0s) ✓ Done (3.0s) diff --git a/packages/mason_logger/test/src/progress_test.dart b/packages/mason_logger/test/src/progress_test.dart index d3fa07cd9..236012d9c 100644 --- a/packages/mason_logger/test/src/progress_test.dart +++ b/packages/mason_logger/test/src/progress_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; +import 'package:mason_logger/src/stdio_overrides.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -12,34 +13,54 @@ class MockStdin extends Mock implements Stdin {} void main() { group('Progress', () { late Stdout stdout; + late StdioType Function(dynamic) stdioType; setUp(() { stdout = MockStdout(); when(() => stdout.supportsAnsiEscapes).thenReturn(true); + stdioType = (dynamic _) => StdioType.terminal; }); test('writes ms when elapsed time is less than 0.1s', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger().progress(message); - await Future.delayed(const Duration(milliseconds: 10)); - progress.complete(); - verify( - () => stdout.write(any(that: matches(RegExp(r'\(\d\dms\)')))), - ).called(1); + const message = 'test message'; + final progress = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 10)); + progress.complete(); + verify( + () => stdout.write(any(that: matches(RegExp(r'\(\d\dms\)')))), + ).called(1); + }, + stdout: () => stdout, + zoneValues: {AnsiCode: true}, + ); + }); + + test('writes static message when stdioType is not terminal', () async { + await _runZoned( + () async { + const message = 'test message'; + final done = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 400)); + done.complete(); + verifyInOrder([ + () => stdout.write('${lightGreen.wrap('⠋')} $message...'), + () { + stdout.write( + '''\u001b[2K\r${lightGreen.wrap('✓')} $message ${darkGray.wrap('(0.4s)')}\n''', + ); }, - stdout: () => stdout, - ); + ]); }, + stdout: () => stdout, + stdioType: () => (dynamic _) => StdioType.other, zoneValues: {AnsiCode: true}, ); }); test('writes custom progress animation to stdout', () async { - await IOOverrides.runZoned( + await _runZoned( () async { const time = '(0.Xs)'; const message = 'test message'; @@ -74,11 +95,12 @@ void main() { }, stdout: () => stdout, stdin: () => stdin, + stdioType: () => stdioType, ); }); test('supports empty list of animation frames', () async { - await IOOverrides.runZoned( + await _runZoned( () async { const time = '(0.Xs)'; const message = 'test message'; @@ -113,62 +135,57 @@ void main() { }, stdout: () => stdout, stdin: () => stdin, + stdioType: () => stdioType, ); }); group('.complete', () { test('writes lines to stdout', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger().progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.complete(); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', - ), - ), + const message = 'test message'; + final progress = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.complete(); + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', ), - ); - }, - ).called(1); + ), + ), + ); + }, + ).called(1); - verify( - () { - stdout.write( - '''\r✓ test message (0.1s)\n''', - ); - }, - ).called(1); + verify( + () { + stdout.write( + '''\r✓ test message (0.1s)\n''', + ); }, - stdout: () => stdout, - ); + ).called(1); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); test('does not write lines to stdout when Level > info', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger(level: Level.warning).progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.complete(); - verifyNever(() => stdout.write(any())); - }, - stdout: () => stdout, - ); + const message = 'test message'; + final progress = Logger(level: Level.warning).progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.complete(); + verifyNever(() => stdout.write(any())); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); @@ -176,68 +193,61 @@ void main() { group('.update', () { test('writes lines to stdout', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'message'; - const update = 'update'; - final progress = Logger().progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.update(update); - await Future.delayed(const Duration(milliseconds: 100)); + const message = 'message'; + const update = 'update'; + final progress = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.update(update); + await Future.delayed(const Duration(milliseconds: 100)); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D\[92m⠙\[0m message... \[90m\(8\dms\)\[0m', - ), - ), + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D\[92m⠙\[0m message... \[90m\(8\dms\)\[0m', ), - ); - }, - ).called(1); + ), + ), + ); + }, + ).called(1); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D\[92m⠹\[0m update... \[90m\(0\.1s\)\[0m', - ), - ), + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D\[92m⠹\[0m update... \[90m\(0\.1s\)\[0m', ), - ); - }, - ).called(1); + ), + ), + ); }, - stdout: () => stdout, - ); + ).called(1); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); test('does not writes to stdout when Level > info', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'message'; - const update = 'update'; - final progress = Logger(level: Level.warning).progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.update(update); - await Future.delayed(const Duration(milliseconds: 100)); - verifyNever(() => stdout.write(any())); - }, - stdout: () => stdout, - ); + const message = 'message'; + const update = 'update'; + final progress = Logger(level: Level.warning).progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.update(update); + await Future.delayed(const Duration(milliseconds: 100)); + verifyNever(() => stdout.write(any())); }, + stdout: () => stdout, zoneValues: {AnsiCode: true}, ); }); @@ -245,59 +255,53 @@ void main() { group('.fail', () { test('writes lines to stdout', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const time = '(0.1s)'; - const message = 'test message'; - final progress = Logger().progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.fail(); + const time = '(0.1s)'; + const message = 'test message'; + final progress = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.fail(); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', - ), - ), + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', ), - ); - }, - ).called(1); + ), + ), + ); + }, + ).called(1); - verify( - () { - stdout.write( - '''\u000D✗ $message $time\n''', - ); - }, - ).called(1); + verify( + () { + stdout.write( + '''\u000D✗ $message $time\n''', + ); }, - stdout: () => stdout, - ); + ).called(1); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); test('does not write to stdout when Level > info', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger(level: Level.warning).progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.fail(); - verifyNever(() => stdout.write(any())); - }, - stdout: () => stdout, - ); + const message = 'test message'; + final progress = Logger(level: Level.warning).progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.fail(); + verifyNever(() => stdout.write(any())); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); @@ -305,66 +309,80 @@ void main() { group('.cancel', () { test('writes lines to stdout', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger().progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.cancel(); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', - ), - ), + const message = 'test message'; + final progress = Logger().progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.cancel(); + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D\[92m⠙\[0m test message... \[90m\(8\dms\)\[0m', ), - ); - }, - ).called(1); + ), + ), + ); + }, + ).called(1); - verify( - () { - stdout.write( - any( - that: matches( - RegExp( - r'\[2K\u000D', - ), - ), + verify( + () { + stdout.write( + any( + that: matches( + RegExp( + r'\[2K\u000D', ), - ); - }, - ).called(1); + ), + ), + ); }, - stdout: () => stdout, - ); + ).called(1); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); test('does not write to stdout when Level > info', () async { - await runZoned( + await _runZoned( () async { - await IOOverrides.runZoned( - () async { - const message = 'test message'; - final progress = Logger(level: Level.warning).progress(message); - await Future.delayed(const Duration(milliseconds: 100)); - progress.cancel(); - verifyNever(() => stdout.write(any())); - }, - stdout: () => stdout, - ); + const message = 'test message'; + final progress = Logger(level: Level.warning).progress(message); + await Future.delayed(const Duration(milliseconds: 100)); + progress.cancel(); + verifyNever(() => stdout.write(any())); }, + stdout: () => stdout, + stdioType: () => stdioType, zoneValues: {AnsiCode: true}, ); }); }); }); } + +T _runZoned( + T Function() body, { + Map? zoneValues, + StdioType Function(dynamic) Function()? stdioType, + Stdin Function()? stdin, + Stdout Function()? stdout, +}) { + return runZoned( + () { + return StdioOverrides.runZoned( + () { + return IOOverrides.runZoned(body, stdout: stdout, stdin: stdin); + }, + stdioType: stdioType, + ); + }, + zoneValues: zoneValues, + ); +} diff --git a/packages/mason_logger/test/src/stdio_overrides_test.dart b/packages/mason_logger/test/src/stdio_overrides_test.dart new file mode 100644 index 000000000..2decb03f7 --- /dev/null +++ b/packages/mason_logger/test/src/stdio_overrides_test.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:mason_logger/src/stdio_overrides.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class FakeStdout extends Fake implements Stdout {} + +class FakeStdin extends Fake implements Stdin {} + +void main() { + group('StdioOverrides', () { + group('runZoned', () { + test('uses default stdioType when not specified', () { + StdioOverrides.runZoned(() { + final overrides = StdioOverrides.current; + expect(overrides!.stdioType, equals(stdioType)); + }); + }); + + test('uses custom stdioType when specified', () { + StdioType customStdioType(dynamic _) => StdioType.pipe; + StdioOverrides.runZoned( + () { + final overrides = StdioOverrides.current; + expect(overrides!.stdioType, equals(customStdioType)); + }, + stdioType: () => customStdioType, + ); + }); + + test( + 'uses current stdioType when not specified ' + 'and zone already contains a stdioType', () { + StdioType customStdioType(dynamic _) => StdioType.pipe; + StdioOverrides.runZoned( + () { + StdioOverrides.runZoned(() { + final overrides = StdioOverrides.current; + expect(overrides!.stdioType, equals(customStdioType)); + }); + }, + stdioType: () => customStdioType, + ); + }); + + test( + 'uses nested stdioType when specified ' + 'and zone already contains a stdioType', () { + StdioType rootStdioType(dynamic _) => StdioType.pipe; + StdioOverrides.runZoned( + () { + StdioType nestedStdioType(dynamic _) => StdioType.other; + final overrides = StdioOverrides.current; + expect(overrides!.stdioType, equals(rootStdioType)); + StdioOverrides.runZoned( + () { + final overrides = StdioOverrides.current; + expect(overrides!.stdioType, equals(nestedStdioType)); + }, + stdioType: () => nestedStdioType, + ); + }, + stdioType: () => rootStdioType, + ); + }); + }); + }); +}