Chromium Code Reviews| Index: lib/src/console_reporter.dart |
| diff --git a/lib/src/console_reporter.dart b/lib/src/console_reporter.dart |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..3faa1b47579b3d98049918a4d91e48a5efef5707 |
| --- /dev/null |
| +++ b/lib/src/console_reporter.dart |
| @@ -0,0 +1,205 @@ |
| +// Copyright (c) 2015, 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. |
| + |
| +library unittest.console_reporter; |
| + |
| +import 'dart:async'; |
| +import 'dart:io'; |
| + |
| +import 'engine.dart'; |
| +import 'io.dart'; |
| +import 'live_test.dart'; |
| +import 'state.dart'; |
| +import 'suite.dart'; |
| +import 'utils.dart'; |
| + |
| +/// The terminal escape for green text, or the empty string if this is Windows |
| +/// or not outputting to a terminal. |
| +final _green = getSpecial('\u001b[32m'); |
| + |
| +/// The terminal escape for red text, or the empty string if this is Windows or |
| +/// not outputting to a terminal. |
| +final _red = getSpecial('\u001b[31m'); |
| + |
| +/// The terminal escape for removing test coloring, or the empty string if this |
| +/// is Windows or not outputting to a terminal. |
| +final _noColor = getSpecial('\u001b[0m'); |
| + |
| +/// The maximum console line length. |
| +/// |
| +/// Lines longer than this will be cropped. |
| +const _lineLength = 80; |
| + |
| +/// A reporter that prints test results to the console in a single |
| +/// continuously-updating line. |
| +class ConsoleReporter { |
| + /// The engine used to run the tests. |
| + final Engine _engine; |
| + |
| + /// Whether multiple test suites are being run. |
| + final bool _multipleSuites; |
| + |
| + /// A stopwatch that tracks the duration of the full run. |
| + final _stopwatch = new Stopwatch(); |
| + |
| + /// The set of tests that have completed and been marked as passing. |
| + final _passed = new Set<LiveTest>(); |
| + |
| + /// The set of tests that have completed and been marked as failing or error. |
| + final _failed = new Set<LiveTest>(); |
| + |
| + /// Creates a [ConsoleReporter] that will run all tests in [suites]. |
| + ConsoleReporter(Iterable<Suite> suites) |
| + : _multipleSuites = suites.length > 1, |
|
kevmoo
2015/02/11 22:37:39
I hate length checks against Iterable -> causes a
nweiz
2015/02/11 23:34:43
Not really, since Engine.liveTests flattens across
|
| + _engine = new Engine(suites) { |
| + |
| + _engine.onTestStarted.listen((liveTest) { |
| + _progressLine(_description(liveTest)); |
| + liveTest.onStateChange.listen((state) { |
| + if (state.status != Status.complete) return; |
| + if (state.result == Result.success) { |
| + _passed.add(liveTest); |
| + } else { |
| + _passed.remove(liveTest); |
| + _failed.add(liveTest); |
| + } |
| + _progressLine(_description(liveTest)); |
| + }); |
| + |
| + liveTest.onError.listen((error) { |
| + if (liveTest.state.status != Status.complete) return; |
| + |
| + // TODO(nweiz): don't re-print the progress line if a test has multiple |
| + // errors in a row. |
| + _progressLine(_description(liveTest)); |
| + print(''); |
| + print(indent("${error.error}\n${error.stackTrace}")); |
| + }); |
| + }); |
| + } |
| + |
| + /// Runs all tests in all provided suites. |
| + /// |
| + /// This returns `true` if all tests succeed, and `false` otherwise. It will |
| + /// only return once all tests have finished running. |
| + Future<bool> run() { |
| + if (_stopwatch.isRunning) { |
| + throw new StateError("ConsoleReporter.run() may not be called more than " |
| + "once."); |
| + } |
| + |
| + _stopwatch.start(); |
| + return _engine.run().then((success) { |
| + if (_engine.liveTests.isEmpty) { |
| + print("\nNo tests ran."); |
| + } else if (success) { |
| + _progressLine("All tests passed!"); |
| + print(''); |
| + } else { |
| + _progressLine('Some tests failed.', color: _red); |
| + print(''); |
| + } |
| + |
| + return success; |
| + }); |
| + } |
| + |
| + /// Prints a line representing the current state of the tests. |
| + /// |
| + /// [message] goes after the progress report, and may be truncated to fit the |
| + /// entire line within [_lineLength]. If [color] is passed, it's used as the |
| + /// color for [message]. |
| + void _progressLine(String message, {String color}) { |
| + if (color == null) color = ''; |
| + var duration = _stopwatch.elapsed; |
| + var buffer = new StringBuffer(); |
| + |
| + // \r moves back to the beginning of the current line. |
| + buffer.write('\r${_timeString(duration)} '); |
| + buffer.write(_green); |
| + buffer.write('+'); |
| + buffer.write(_passed.length); |
| + buffer.write(_noColor); |
| + |
| + if (_failed.isNotEmpty) { |
| + buffer.write(_red); |
| + buffer.write(' -'); |
| + buffer.write(_failed.length); |
| + buffer.write(_noColor); |
| + } |
| + |
| + buffer.write(': '); |
| + buffer.write(color); |
| + |
| + // Ensure the line fits within [_lineLength]. [buffer] includes the color |
| + // escape sequences too. Because these sequences are not visible characters, |
| + // we make sure they are not counted towards the limit. |
| + var nonVisible = 1 + _green.length + _noColor.length + color.length + |
| + (_failed.isEmpty ? 0 : _red.length + _noColor.length); |
| + var length = buffer.length - nonVisible; |
| + buffer.write(_truncate(message, _lineLength - length)); |
| + buffer.write(_noColor); |
| + |
| + // Pad the rest of the line so that it looks erased. |
| + length = buffer.length - nonVisible - _noColor.length; |
| + buffer.write(' ' * (_lineLength - length)); |
| + stdout.write(buffer.toString()); |
| + } |
| + |
| + /// Returns a representation of [duration] as `MM:SS`. |
| + String _timeString(Duration duration) { |
| + return "${duration.inMinutes.toString().padLeft(2, '0')}:" |
| + "${(duration.inSeconds % 60).toString().padLeft(2, '0')}"; |
| + } |
| + |
| + /// Truncates [text] to fit within [maxLength]. |
| + /// |
| + /// This will try to truncate along word boundaries and preserve words both at |
| + /// the beginning and the end of [text]. |
| + String _truncate(String text, int maxLength) { |
| + // Return the full message if it fits. |
| + if (text.length <= maxLength) return text; |
| + |
| + // If we can fit the first and last three words, do so. |
| + var words = text.split(' '); |
| + if (words.length > 1) { |
| + var i = words.length; |
| + var length = words.first.length + 4; |
| + do { |
| + i--; |
| + length += 1 + words[i].length; |
| + } while (length <= maxLength && i > 0); |
| + if (length > maxLength || i == 0) i++; |
| + if (i < words.length - 4) { |
| + // Require at least 3 words at the end. |
| + var buffer = new StringBuffer(); |
| + buffer.write(words.first); |
| + buffer.write(' ...'); |
| + for ( ; i < words.length; i++) { |
| + buffer.write(' '); |
| + buffer.write(words[i]); |
| + } |
| + return buffer.toString(); |
| + } |
| + } |
| + |
| + // Otherwise truncate to return the trailing text, but attempt to start at |
| + // the beginning of a word. |
| + var result = text.substring(text.length - maxLength + 4); |
| + var firstSpace = result.indexOf(' '); |
| + if (firstSpace > 0) { |
| + result = result.substring(firstSpace); |
| + } |
| + return '...$result'; |
| + } |
| + |
| + /// Returns a description of [liveTest]. |
| + /// |
| + /// This differs from the test's own description in that it may also include |
| + /// the suite's name. |
| + String _description(LiveTest liveTest) { |
| + if (_multipleSuites) return "${liveTest.suite.name}: ${liveTest.test.name}"; |
| + return liveTest.test.name; |
| + } |
| +} |