| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| @TestOn('vm') |
| library; |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:pub/src/log.dart' as log; |
| import 'package:pub/src/progress.dart'; |
| import 'package:pub/src/utils.dart'; |
| import 'package:test/test.dart'; |
| |
| final class _MockStdout implements Stdout { |
| final StringBuffer buffer = StringBuffer(); |
| |
| @override |
| void write(Object? object) { |
| buffer.write(object); |
| } |
| |
| @override |
| void writeln([Object? object = '']) { |
| buffer.writeln(object); |
| } |
| |
| @override |
| void writeAll(Iterable objects, [String sep = '']) { |
| buffer.writeAll(objects, sep); |
| } |
| |
| @override |
| void writeCharCode(int charCode) { |
| buffer.writeCharCode(charCode); |
| } |
| |
| @override |
| void add(List<int> data) { |
| buffer.write(utf8.decode(data)); |
| } |
| |
| @override |
| void addError(Object error, [StackTrace? stackTrace]) {} |
| |
| @override |
| Future addStream(Stream<List<int>> stream) async { |
| await for (final chunk in stream) { |
| buffer.write(utf8.decode(chunk)); |
| } |
| } |
| |
| @override |
| Future close() async {} |
| |
| @override |
| Future get done => Future.value(); |
| |
| @override |
| Future flush() async {} |
| |
| @override |
| Encoding encoding = utf8; |
| |
| final bool _hasTerminal; |
| |
| _MockStdout({bool hasTerminal = true}) : _hasTerminal = hasTerminal; |
| |
| @override |
| bool get hasTerminal => _hasTerminal; |
| |
| @override |
| IOSink get nonBlocking => this; |
| |
| @override |
| bool get supportsAnsiEscapes => true; |
| |
| @override |
| int get terminalColumns => 80; |
| |
| @override |
| int get terminalLines => 25; |
| |
| @override |
| String get lineTerminator => '\n'; |
| |
| @override |
| set lineTerminator(String value) {} |
| } |
| |
| Future<void> _runWithMockStdout( |
| FutureOr<void> Function(_MockStdout mockStdout) callback, { |
| bool hasTerminal = true, |
| }) async { |
| final mockStdout = _MockStdout(hasTerminal: hasTerminal); |
| await IOOverrides.runZoned( |
| () => callback(mockStdout), |
| stdout: () => mockStdout, |
| ); |
| } |
| |
| void main() { |
| setUp(resetGracePeriod); |
| |
| tearDown(resetGracePeriod); |
| |
| test( |
| 'stopAndClear erases line with ANSI escape sequence', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| final progress = Progress('Resolving dependencies'); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies... '); |
| progress.stopAndClear(); |
| expect( |
| mockStdout.buffer.toString(), |
| 'Resolving dependencies... \r${log.eraseLine}', |
| ); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'transient progress produces no output when ANSI is disabled', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.never; |
| try { |
| final progress = Progress('Resolving dependencies', transient: true); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| progress.stopAndClear(); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'non-transient progress logs once without animation when ANSI is disabled', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.never; |
| try { |
| final progress = Progress('Resolving dependencies'); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies...\n'); |
| progress.stop(); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies...\n'); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'stop prints completed newline', |
| () => _runWithMockStdout((mockStdout) async { |
| final progress = Progress('Resolving dependencies'); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies... '); |
| progress.stop(); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies... \n'); |
| }), |
| ); |
| |
| test( |
| 'delayed progress writes nothing if stopped before delay', |
| () => _runWithMockStdout((mockStdout) async { |
| final progress = Progress( |
| 'Resolving dependencies', |
| delay: const Duration(milliseconds: 200), |
| ); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| progress.stopAndClear(); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| }), |
| ); |
| |
| test( |
| 'delayed progress writes and clears if stopped after delay', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| final progress = Progress( |
| 'Resolving dependencies', |
| delay: const Duration(milliseconds: 50), |
| ); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| await Future<void>.delayed(const Duration(milliseconds: 150)); |
| expect(mockStdout.buffer.toString(), 'Resolving dependencies... '); |
| progress.stopAndClear(); |
| expect( |
| mockStdout.buffer.toString(), |
| 'Resolving dependencies... \r${log.eraseLine}', |
| ); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'subsequent spinner starts immediately once progress was shown', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| currentProgressGracePeriod.markProgressShown(); |
| final progress = Progress('Downloading packages'); |
| expect(mockStdout.buffer.toString(), 'Downloading packages... '); |
| progress.stopAndClear(); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| resetGracePeriod(); |
| } |
| }), |
| ); |
| |
| test( |
| 'withProgressGracePeriod shares progress state across zone', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| final customGrace = ProgressGracePeriod(); |
| await withProgressGracePeriod(() async { |
| expect(currentProgressGracePeriod, same(customGrace)); |
| expect(currentProgressGracePeriod.hasShownProgress, isFalse); |
| final progress = Progress('Resolving'); |
| expect(currentProgressGracePeriod.hasShownProgress, isTrue); |
| expect(customGrace.hasShownProgress, isTrue); |
| progress.stopAndClear(); |
| }, progressGracePeriod: customGrace); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'progress cleans up and stops timer on synchronous exception', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| expect( |
| () => log.progress<void>( |
| 'Failing task', |
| () => throw StateError('boom'), |
| ), |
| throwsA(isA<StateError>()), |
| ); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'stopAnimating erases elapsed time before newline', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| final progress = Progress('Animating task', delay: Duration.zero); |
| // Wait long enough for timer to tick and print time indicator |
| await Future<void>.delayed(const Duration(milliseconds: 1100)); |
| progress.stopAnimating(); |
| final output = mockStdout.buffer.toString(); |
| expect(output, contains('\rAnimating task... ${log.eraseToLineEnd}\n')); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'stopAnimating erases entire line when transient', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.always; |
| try { |
| final progress = Progress( |
| 'Transient task', |
| delay: Duration.zero, |
| transient: true, |
| ); |
| progress.stopAnimating(); |
| final output = mockStdout.buffer.toString(); |
| expect(output, 'Transient task... \r${log.eraseLine}'); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test( |
| 'stopAnimating is a no-op when ANSI is disabled', |
| () => _runWithMockStdout((mockStdout) async { |
| forceColors = ForceColorOption.never; |
| try { |
| final progress = Progress('Animating task', delay: Duration.zero); |
| expect(mockStdout.buffer.toString(), 'Animating task...\n'); |
| progress.stopAnimating(); |
| expect(mockStdout.buffer.toString(), 'Animating task...\n'); |
| } finally { |
| forceColors = ForceColorOption.auto; |
| } |
| }), |
| ); |
| |
| test('ProgressGracePeriod rejects negative defaultGracePeriod', () { |
| expect( |
| () => ProgressGracePeriod( |
| defaultGracePeriod: const Duration(milliseconds: -1), |
| ), |
| throwsArgumentError, |
| ); |
| }); |
| |
| test( |
| 'log.progress supports synchronous callbacks', |
| () => _runWithMockStdout((mockStdout) async { |
| final result = await log.progress('Sync task', () => 42); |
| expect(result, 42); |
| }), |
| ); |
| |
| test('ProgressGracePeriod rejects an unstarted stopwatch', () { |
| final stopwatch = Stopwatch(); |
| expect( |
| () => ProgressGracePeriod(stopwatch: stopwatch), |
| throwsArgumentError, |
| ); |
| }); |
| |
| test('ProgressGracePeriod accepts a running stopwatch', () { |
| final stopwatch = Stopwatch()..start(); |
| final grace = ProgressGracePeriod(stopwatch: stopwatch); |
| expect( |
| grace.remainingDelay, |
| lessThanOrEqualTo(const Duration(milliseconds: 500)), |
| ); |
| }); |
| |
| test( |
| 'transient progress produces no output when stdout has no terminal', |
| () => _runWithMockStdout(hasTerminal: false, (mockStdout) async { |
| final result = await log.progress( |
| 'Transient task', |
| () async => 42, |
| transient: true, |
| ); |
| expect(result, 42); |
| expect(mockStdout.buffer.toString(), isEmpty); |
| }), |
| ); |
| } |