| // 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); | |
| }), | |
| ); | |
| } |