Add failure only reporter (#2190)
Closes #829
Copies the expanded reporter and test and removes output unrelated to
failed tests.
diff --git a/pkgs/test/pubspec.yaml b/pkgs/test/pubspec.yaml
index 531fb20..6439481 100644
--- a/pkgs/test/pubspec.yaml
+++ b/pkgs/test/pubspec.yaml
@@ -35,7 +35,7 @@
# Use an exact version until the test_api and test_core package are stable.
test_api: 0.7.0
- test_core: 0.6.0
+ test_core: 0.6.1
typed_data: ^1.3.0
web_socket_channel: ^2.0.0
diff --git a/pkgs/test/test/runner/failures_only_reporter_test.dart b/pkgs/test/test/runner/failures_only_reporter_test.dart
new file mode 100644
index 0000000..3e2690a
--- /dev/null
+++ b/pkgs/test/test/runner/failures_only_reporter_test.dart
@@ -0,0 +1,257 @@
+// Copyright (c) 2024, 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 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import '../io.dart';
+
+void main() {
+ setUpAll(precompileTestExecutable);
+
+ test('reports when no tests are run', () async {
+ await d.file('test.dart', 'void main() {}').create();
+
+ var test = await runTest(['test.dart'], reporter: 'failures-only');
+ expect(test.stdout, emitsThrough(contains('No tests ran.')));
+ await test.shouldExit(79);
+ });
+
+ test('runs several successful tests and reports only at the end', () {
+ return _expectReport('''
+ test('success 1', () {});
+ test('success 2', () {});
+ test('success 3', () {});''', '''
+ +3: All tests passed!''');
+ });
+
+ test('runs several failing tests and reports when each fails', () {
+ return _expectReport('''
+ test('failure 1', () => throw TestFailure('oh no'));
+ test('failure 2', () => throw TestFailure('oh no'));
+ test('failure 3', () => throw TestFailure('oh no'));''', '''
+ +0 -1: failure 1 [E]
+ oh no
+ test.dart 6:33 main.<fn>
+
+ +0 -2: failure 2 [E]
+ oh no
+ test.dart 7:33 main.<fn>
+
+ +0 -3: failure 3 [E]
+ oh no
+ test.dart 8:33 main.<fn>
+
+ +0 -3: Some tests failed.''');
+ });
+
+ test('includes the full stack trace with --verbose-trace', () async {
+ await d.file('test.dart', '''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+ test("failure", () => throw "oh no");
+}
+''').create();
+
+ var test = await runTest(['--verbose-trace', 'test.dart'],
+ reporter: 'failures-only');
+ expect(test.stdout, emitsThrough(contains('dart:async')));
+ await test.shouldExit(1);
+ });
+
+ test('reports only failing tests amid successful tests', () {
+ return _expectReport('''
+ test('failure 1', () => throw TestFailure('oh no'));
+ test('success 1', () {});
+ test('failure 2', () => throw TestFailure('oh no'));
+ test('success 2', () {});''', '''
+ +0 -1: failure 1 [E]
+ oh no
+ test.dart 6:33 main.<fn>
+
+ +1 -2: failure 2 [E]
+ oh no
+ test.dart 8:33 main.<fn>
+
+ +2 -2: Some tests failed.''');
+ });
+
+ group('print:', () {
+ test('handles multiple prints', () {
+ return _expectReport('''
+ test('test', () {
+ print("one");
+ print("two");
+ print("three");
+ print("four");
+ });''', '''
+ +0: test
+ one
+ two
+ three
+ four
+ +1: All tests passed!''');
+ });
+
+ test('handles a print after the test completes', () {
+ return _expectReport('''
+ // This completer ensures that the test isolate isn't killed until all
+ // prints have happened.
+ var testDone = Completer();
+ var waitStarted = Completer();
+ test('test', () async {
+ waitStarted.future.then((_) {
+ Future(() => print("one"));
+ Future(() => print("two"));
+ Future(() => print("three"));
+ Future(() => print("four"));
+ Future(testDone.complete);
+ });
+ });
+
+ test('wait', () {
+ waitStarted.complete();
+ return testDone.future;
+ });''', '''
+ +1: test
+ one
+ two
+ three
+ four
+ +2: All tests passed!''');
+ });
+
+ test('interleaves prints and errors', () {
+ return _expectReport('''
+ // This completer ensures that the test isolate isn't killed until all
+ // prints have happened.
+ var completer = Completer();
+ test('test', () {
+ scheduleMicrotask(() {
+ print("three");
+ print("four");
+ throw "second error";
+ });
+
+ scheduleMicrotask(() {
+ print("five");
+ print("six");
+ completer.complete();
+ });
+
+ print("one");
+ print("two");
+ throw "first error";
+ });
+
+ test('wait', () => completer.future);''', '''
+ +0: test
+ one
+ two
+ +0 -1: test [E]
+ first error
+ test.dart 24:11 main.<fn>
+
+ three
+ four
+ second error
+ test.dart 13:13 main.<fn>.<fn>
+ ===== asynchronous gap ===========================
+ dart:async scheduleMicrotask
+ test.dart 10:11 main.<fn>
+
+ five
+ six
+ +1 -1: Some tests failed.''');
+ });
+ });
+
+ group('skip:', () {
+ test('does not emit for skips', () {
+ return _expectReport('''
+ test('skip 1', () {}, skip: true);
+ test('skip 2', () {}, skip: true);
+ test('skip 3', () {}, skip: true);''', '''
+ +0 ~3: All tests skipped.''');
+ });
+
+ test('runs skipped tests along with successful and failing tests', () {
+ return _expectReport('''
+ test('failure 1', () => throw TestFailure('oh no'));
+ test('skip 1', () {}, skip: true);
+ test('success 1', () {});
+ test('failure 2', () => throw TestFailure('oh no'));
+ test('skip 2', () {}, skip: true);
+ test('success 2', () {});''', '''
+ +0 -1: failure 1 [E]
+ oh no
+ test.dart 6:35 main.<fn>
+
+ +1 ~1 -2: failure 2 [E]
+ oh no
+ test.dart 9:35 main.<fn>
+
+ +2 ~2 -2: Some tests failed.''');
+ });
+ });
+
+ test('Directs users to enable stack trace chaining if disabled', () async {
+ await _expectReport(
+ '''test('failure 1', () => throw TestFailure('oh no'));''', '''
+ +0 -1: failure 1 [E]
+ oh no
+ test.dart 6:25 main.<fn>
+
+ +0 -1: Some tests failed.
+
+ Consider enabling the flag chain-stack-traces to receive more detailed exceptions.
+ For example, 'dart test --chain-stack-traces'.''',
+ chainStackTraces: false);
+ });
+}
+
+Future<void> _expectReport(String tests, String expected,
+ {List<String> args = const [], bool chainStackTraces = true}) async {
+ await d.file('test.dart', '''
+ import 'dart:async';
+
+ import 'package:test/test.dart';
+
+ void main() {
+$tests
+ }
+ ''').create();
+
+ var test = await runTest([
+ 'test.dart',
+ if (chainStackTraces) '--chain-stack-traces',
+ ...args,
+ ], reporter: 'failures-only');
+ await test.shouldExit();
+
+ var stdoutLines = await test.stdoutStream().toList();
+
+ // Remove excess trailing whitespace.
+ var actual = stdoutLines.map((line) {
+ if (line.startsWith(' ') || line.isEmpty) return line.trimRight();
+ return line.trim();
+ }).join('\n');
+
+ // Un-indent the expected string.
+ var indentation = expected.indexOf(RegExp('[^ ]'));
+ expected = expected.split('\n').map((line) {
+ if (line.isEmpty) return line;
+ return line.substring(indentation);
+ }).join('\n');
+
+ expect(actual, equals(expected));
+}
diff --git a/pkgs/test/test/runner/runner_test.dart b/pkgs/test/test/runner/runner_test.dart
index dde3d04..c16ac51 100644
--- a/pkgs/test/test/runner/runner_test.dart
+++ b/pkgs/test/test/runner/runner_test.dart
@@ -108,6 +108,7 @@
[compact] A single line, updated continuously.
[expanded] (default) A separate line for each update.
+ [failures-only] A separate line for failing tests with no output for passing tests
[github] A custom reporter for GitHub Actions (the default reporter when running on GitHub Actions).
[json] A machine-readable format (see https://dart.dev/go/test-docs/json_reporter.md).
[silent] A reporter with no output. May be useful when only the exit code is meaningful.
diff --git a/pkgs/test_core/lib/src/runner/configuration/reporters.dart b/pkgs/test_core/lib/src/runner/configuration/reporters.dart
index a79912f..0f0fe01 100644
--- a/pkgs/test_core/lib/src/runner/configuration/reporters.dart
+++ b/pkgs/test_core/lib/src/runner/configuration/reporters.dart
@@ -11,6 +11,7 @@
import '../reporter.dart';
import '../reporter/compact.dart';
import '../reporter/expanded.dart';
+import '../reporter/failures_only.dart';
import '../reporter/github.dart';
import '../reporter/json.dart';
@@ -46,6 +47,14 @@
Directory(config.testSelections.keys.single).existsSync(),
printPlatform: config.suiteDefaults.runtimes.length > 1 ||
config.suiteDefaults.compilerSelections != null)),
+ 'failures-only': ReporterDetails(
+ 'A separate line for failing tests with no output for passing tests',
+ (config, engine, sink) => FailuresOnlyReporter.watch(engine, sink,
+ color: config.color,
+ printPath: config.testSelections.length > 1 ||
+ Directory(config.testSelections.keys.single).existsSync(),
+ printPlatform: config.suiteDefaults.runtimes.length > 1 ||
+ config.suiteDefaults.compilerSelections != null)),
'github': ReporterDetails(
'A custom reporter for GitHub Actions '
'(the default reporter when running on GitHub Actions).',
diff --git a/pkgs/test_core/lib/src/runner/reporter/failures_only.dart b/pkgs/test_core/lib/src/runner/reporter/failures_only.dart
new file mode 100644
index 0000000..b6b85e7
--- /dev/null
+++ b/pkgs/test_core/lib/src/runner/reporter/failures_only.dart
@@ -0,0 +1,295 @@
+// Copyright (c) 2024, 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.
+
+import 'dart:async';
+
+import 'package:test_api/src/backend/live_test.dart'; // ignore: implementation_imports
+import 'package:test_api/src/backend/message.dart'; // ignore: implementation_imports
+import 'package:test_api/src/backend/state.dart'; // ignore: implementation_imports
+
+import '../../util/pretty_print.dart';
+import '../engine.dart';
+import '../load_exception.dart';
+import '../load_suite.dart';
+import '../reporter.dart';
+
+/// A reporter that only prints when a test fails.
+class FailuresOnlyReporter implements Reporter {
+ /// Whether the reporter should emit terminal color escapes.
+ final bool _color;
+
+ /// The terminal escape for green text, or the empty string if this is Windows
+ /// or not outputting to a terminal.
+ final String _green;
+
+ /// The terminal escape for red text, or the empty string if this is Windows
+ /// or not outputting to a terminal.
+ final String _red;
+
+ /// The terminal escape for yellow text, or the empty string if this is
+ /// Windows or not outputting to a terminal.
+ final String _yellow;
+
+ /// The terminal escape for gray text, or the empty string if this is
+ /// Windows or not outputting to a terminal.
+ final String _gray;
+
+ /// The terminal escape for bold text, or the empty string if this is
+ /// Windows or not outputting to a terminal.
+ final String _bold;
+
+ /// The terminal escape for removing test coloring, or the empty string if
+ /// this is Windows or not outputting to a terminal.
+ final String _noColor;
+
+ /// The engine used to run the tests.
+ final Engine _engine;
+
+ /// Whether the path to each test's suite should be printed.
+ final bool _printPath;
+
+ /// Whether the platform each test is running on should be printed.
+ final bool _printPlatform;
+
+ /// The size of `_engine.passed` last time a progress notification was
+ /// printed.
+ int _lastProgressPassed = 0;
+
+ /// The size of `_engine.skipped` last time a progress notification was
+ /// printed.
+ int _lastProgressSkipped = 0;
+
+ /// The size of `_engine.failed` last time a progress notification was
+ /// printed.
+ int _lastProgressFailed = 0;
+
+ /// The message printed for the last progress notification.
+ String _lastProgressMessage = '';
+
+ /// The suffix added to the last progress notification.
+ String? _lastProgressSuffix;
+
+ /// Whether the reporter is paused.
+ var _paused = false;
+
+ // Whether a notice should be logged about enabling stack trace chaining at
+ // the end of all tests running.
+ var _shouldPrintStackTraceChainingNotice = false;
+
+ /// The set of all subscriptions to various streams.
+ final _subscriptions = <StreamSubscription>{};
+
+ final StringSink _sink;
+
+ /// Watches the tests run by [engine] and prints their results to the
+ /// terminal.
+ ///
+ /// If [color] is `true`, this will use terminal colors; if it's `false`, it
+ /// won't. If [printPath] is `true`, this will print the path name as part of
+ /// the test description. Likewise, if [printPlatform] is `true`, this will
+ /// print the platform as part of the test description.
+ static FailuresOnlyReporter watch(Engine engine, StringSink sink,
+ {required bool color,
+ required bool printPath,
+ required bool printPlatform}) =>
+ FailuresOnlyReporter._(engine, sink,
+ color: color, printPath: printPath, printPlatform: printPlatform);
+
+ FailuresOnlyReporter._(this._engine, this._sink,
+ {required bool color,
+ required bool printPath,
+ required bool printPlatform})
+ : _printPath = printPath,
+ _printPlatform = printPlatform,
+ _color = color,
+ _green = color ? '\u001b[32m' : '',
+ _red = color ? '\u001b[31m' : '',
+ _yellow = color ? '\u001b[33m' : '',
+ _gray = color ? '\u001b[90m' : '',
+ _bold = color ? '\u001b[1m' : '',
+ _noColor = color ? '\u001b[0m' : '' {
+ _subscriptions.add(_engine.onTestStarted.listen(_onTestStarted));
+
+ // Convert the future to a stream so that the subscription can be paused or
+ // canceled.
+ _subscriptions.add(_engine.success.asStream().listen(_onDone));
+ }
+
+ @override
+ void pause() {
+ if (_paused) return;
+ _paused = true;
+
+ for (var subscription in _subscriptions) {
+ subscription.pause();
+ }
+ }
+
+ @override
+ void resume() {
+ if (!_paused) return;
+
+ for (var subscription in _subscriptions) {
+ subscription.resume();
+ }
+ }
+
+ void _cancel() {
+ for (var subscription in _subscriptions) {
+ subscription.cancel();
+ }
+ _subscriptions.clear();
+ }
+
+ /// A callback called when the engine begins running [liveTest].
+ void _onTestStarted(LiveTest liveTest) {
+ _subscriptions.add(liveTest.onError
+ .listen((error) => _onError(liveTest, error.error, error.stackTrace)));
+
+ _subscriptions.add(liveTest.onMessage.listen((message) {
+ // TODO - Should this suppress output? Behave like printOnFailure?
+ _progressLine(_description(liveTest));
+ var text = message.text;
+ if (message.type == MessageType.skip) text = ' $_yellow$text$_noColor';
+ _sink.writeln(text);
+ }));
+ }
+
+ /// A callback called when [liveTest] throws [error].
+ void _onError(LiveTest liveTest, Object error, StackTrace stackTrace) {
+ if (!liveTest.test.metadata.chainStackTraces &&
+ !liveTest.suite.isLoadSuite) {
+ _shouldPrintStackTraceChainingNotice = true;
+ }
+
+ if (liveTest.state.status != Status.complete) return;
+
+ _progressLine(_description(liveTest), suffix: ' $_bold$_red[E]$_noColor');
+
+ if (error is! LoadException) {
+ _sink
+ ..writeln(indent('$error'))
+ ..writeln(indent('$stackTrace'));
+ return;
+ }
+
+ // TODO - what type is this?
+ _sink.writeln(indent(error.toString(color: _color)));
+
+ // Only print stack traces for load errors that come from the user's code.
+ if (error.innerError is! FormatException && error.innerError is! String) {
+ _sink.writeln(indent('$stackTrace'));
+ }
+ }
+
+ /// A callback called when the engine is finished running tests.
+ ///
+ /// [success] will be `true` if all tests passed, `false` if some tests
+ /// failed, and `null` if the engine was closed prematurely.
+ void _onDone(bool? success) {
+ _cancel();
+ // A null success value indicates that the engine was closed before the
+ // tests finished running, probably because of a signal from the user, in
+ // which case we shouldn't print summary information.
+ if (success == null) return;
+
+ if (_engine.liveTests.isEmpty) {
+ _sink.writeln('No tests ran.');
+ } else if (!success) {
+ for (var liveTest in _engine.active) {
+ _progressLine(_description(liveTest),
+ suffix: ' - did not complete $_bold$_red[E]$_noColor');
+ }
+ _progressLine('Some tests failed.', color: _red);
+ } else if (_engine.passed.isEmpty) {
+ _progressLine('All tests skipped.');
+ } else {
+ _progressLine('All tests passed!');
+ }
+
+ if (_shouldPrintStackTraceChainingNotice) {
+ _sink
+ ..writeln('')
+ ..writeln('Consider enabling the flag chain-stack-traces to '
+ 'receive more detailed exceptions.\n'
+ "For example, 'dart test --chain-stack-traces'.");
+ }
+ }
+
+ /// Prints a line representing the current state of the tests.
+ ///
+ /// [message] goes after the progress report. If [color] is passed, it's used
+ /// as the color for [message]. If [suffix] is passed, it's added to the end
+ /// of [message].
+ void _progressLine(String message, {String? color, String? suffix}) {
+ // Print nothing if nothing has changed since the last progress line.
+ if (_engine.passed.length == _lastProgressPassed &&
+ _engine.skipped.length == _lastProgressSkipped &&
+ _engine.failed.length == _lastProgressFailed &&
+ message == _lastProgressMessage &&
+ // Don't re-print just because a suffix was removed.
+ (suffix == null || suffix == _lastProgressSuffix)) {
+ return;
+ }
+
+ _lastProgressPassed = _engine.passed.length;
+ _lastProgressSkipped = _engine.skipped.length;
+ _lastProgressFailed = _engine.failed.length;
+ _lastProgressMessage = message;
+ _lastProgressSuffix = suffix;
+
+ if (suffix != null) message += suffix;
+ color ??= '';
+ var buffer = StringBuffer();
+
+ buffer.write(_green);
+ buffer.write('+');
+ buffer.write(_engine.passed.length);
+ buffer.write(_noColor);
+
+ if (_engine.skipped.isNotEmpty) {
+ buffer.write(_yellow);
+ buffer.write(' ~');
+ buffer.write(_engine.skipped.length);
+ buffer.write(_noColor);
+ }
+
+ if (_engine.failed.isNotEmpty) {
+ buffer.write(_red);
+ buffer.write(' -');
+ buffer.write(_engine.failed.length);
+ buffer.write(_noColor);
+ }
+
+ buffer.write(': ');
+ buffer.write(color);
+ buffer.write(message);
+ buffer.write(_noColor);
+
+ _sink.writeln(buffer.toString());
+ }
+
+ /// 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) {
+ var name = liveTest.test.name;
+
+ if (_printPath &&
+ liveTest.suite is! LoadSuite &&
+ liveTest.suite.path != null) {
+ name = '${liveTest.suite.path}: $name';
+ }
+
+ if (_printPlatform) {
+ name = '[${liveTest.suite.platform.runtime.name}, '
+ '${liveTest.suite.platform.compiler.name}] $name';
+ }
+
+ if (liveTest.suite is LoadSuite) name = '$_bold$_gray$name$_noColor';
+
+ return name;
+ }
+}