From 4dddf1146ff6e207d2e299dd5a375f5110f05bc1 Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Tue, 3 Mar 2015 12:05:18 -0800
Subject: [PATCH] Add a variant on ConsoleReporter that doesn't import dart:io.

This is necessary to avoid an IO dependency in lib/unittest.dart, which would
prevent browser tests from running.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//971123002
---
 bin/unittest.dart                             |   4 +-
 .../compact.dart}                             |  61 +-----
 lib/src/runner/reporter/no_io_compact.dart    | 190 ++++++++++++++++++
 lib/src/utils.dart                            |  41 ++++
 lib/unittest.dart                             |   5 +-
 ...r_test.dart => compact_reporter_test.dart} |   0
 6 files changed, 245 insertions(+), 56 deletions(-)
 rename lib/src/runner/{console_reporter.dart => reporter/compact.dart} (77%)
 create mode 100644 lib/src/runner/reporter/no_io_compact.dart
 rename test/{console_reporter_test.dart => compact_reporter_test.dart} (100%)

diff --git a/bin/unittest.dart b/bin/unittest.dart
index 6836ee4e..b2d70acc 100644
--- a/bin/unittest.dart
+++ b/bin/unittest.dart
@@ -11,7 +11,7 @@ import 'dart:isolate';
 import 'package:args/args.dart';
 import 'package:stack_trace/stack_trace.dart';
 
-import 'package:unittest/src/runner/console_reporter.dart';
+import 'package:unittest/src/runner/reporter/compact.dart';
 import 'package:unittest/src/runner/load_exception.dart';
 import 'package:unittest/src/runner/loader.dart';
 import 'package:unittest/src/util/exit_codes.dart' as exit_codes;
@@ -62,7 +62,7 @@ void main(List<String> args) {
   }).then((suites) {
     var color = options["color"];
     if (color == null) color = canUseSpecialChars;
-    var reporter = new ConsoleReporter(flatten(suites), color: color);
+    var reporter = new CompactReporter(flatten(suites), color: color);
     return reporter.run().then((success) {
       exitCode = success ? 0 : 1;
     }).whenComplete(() => reporter.close());
diff --git a/lib/src/runner/console_reporter.dart b/lib/src/runner/reporter/compact.dart
similarity index 77%
rename from lib/src/runner/console_reporter.dart
rename to lib/src/runner/reporter/compact.dart
index 4680146b..a0335a38 100644
--- a/lib/src/runner/console_reporter.dart
+++ b/lib/src/runner/reporter/compact.dart
@@ -2,16 +2,16 @@
 // 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.runner.console_reporter;
+library unittest.runner.reporter.compact;
 
 import 'dart:async';
 import 'dart:io';
 
-import '../backend/live_test.dart';
-import '../backend/state.dart';
-import '../backend/suite.dart';
-import '../utils.dart';
-import 'engine.dart';
+import '../../backend/live_test.dart';
+import '../../backend/state.dart';
+import '../../backend/suite.dart';
+import '../../utils.dart';
+import '../engine.dart';
 
 /// The maximum console line length.
 ///
@@ -20,7 +20,7 @@ const _lineLength = 100;
 
 /// A reporter that prints test results to the console in a single
 /// continuously-updating line.
-class ConsoleReporter {
+class CompactReporter {
   /// The terminal escape for green text, or the empty string if this is Windows
   /// or not outputting to a terminal.
   final String _green;
@@ -61,7 +61,7 @@ class ConsoleReporter {
   ///
   /// If [color] is `true`, this will use terminal colors; if it's `false`, it
   /// won't.
-  ConsoleReporter(Iterable<Suite> suites, {bool color: true})
+  CompactReporter(Iterable<Suite> suites, {bool color: true})
       : _multipleSuites = suites.length > 1,
         _engine = new Engine(suites),
         _green = color ? '\u001b[32m' : '',
@@ -97,7 +97,7 @@ class ConsoleReporter {
   /// 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 "
+      throw new StateError("CompactReporter.run() may not be called more than "
           "once.");
     }
 
@@ -168,7 +168,7 @@ class ConsoleReporter {
     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(truncate(message, _lineLength - length));
     buffer.write(_noColor);
 
     // Pad the rest of the line so that it looks erased.
@@ -183,47 +183,6 @@ class ConsoleReporter {
         "${(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
diff --git a/lib/src/runner/reporter/no_io_compact.dart b/lib/src/runner/reporter/no_io_compact.dart
new file mode 100644
index 00000000..a947730e
--- /dev/null
+++ b/lib/src/runner/reporter/no_io_compact.dart
@@ -0,0 +1,190 @@
+// 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.runner.reporter.no_io_compact;
+
+import 'dart:async';
+
+import '../../backend/live_test.dart';
+import '../../backend/state.dart';
+import '../../backend/suite.dart';
+import '../../utils.dart';
+import '../engine.dart';
+
+/// The maximum console line length.
+///
+/// Lines longer than this will be cropped.
+const _lineLength = 100;
+
+// TODO(nweiz): Get rid of this when issue 6943 is fixed.
+/// A reporter that doesn't import `dart:io`, even transitively.
+///
+/// This is used in place of [CompactReporter] by `lib/unittest.dart`, which
+/// can't transitively import `dart:io` but still needs access to a runner so
+/// that test files can be run directly.
+class NoIoCompactReporter {
+  /// 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 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 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>();
+
+  /// The size of [_passed] last time a progress notification was printed.
+  int _lastProgressPassed;
+
+  /// The size of [_failed] last time a progress notification was printed.
+  int _lastProgressFailed;
+
+  /// The message printed for the last progress notification.
+  String _lastProgressMessage;
+
+  /// Creates a [NoIoCompactReporter] that will run all tests in [suites].
+  ///
+  /// If [color] is `true`, this will use terminal colors; if it's `false`, it
+  /// won't.
+  NoIoCompactReporter(Iterable<Suite> suites, {bool color: true})
+      : _multipleSuites = suites.length > 1,
+        _engine = new Engine(suites),
+        _green = color ? '\u001b[32m' : '',
+        _red = color ? '\u001b[31m' : '',
+        _noColor = color ? '\u001b[0m' : '' {
+    _engine.onTestStarted.listen((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;
+
+        _progressLine(_description(liveTest));
+        print(indent(error.error.toString()));
+        print(indent(terseChain(error.stackTrace).toString()));
+      });
+    });
+  }
+
+  /// 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("CompactReporter.run() may not be called more than "
+          "once.");
+    }
+
+    if (_engine.liveTests.isEmpty) {
+      print("No tests ran.");
+      return new Future.value(true);
+    }
+
+    _stopwatch.start();
+    return _engine.run().then((success) {
+      if (success) {
+        _progressLine("All tests passed!");
+      } else {
+        _progressLine('Some tests failed.', color: _red);
+      }
+
+      return success;
+    });
+  }
+
+  /// Signals that the caller is done with any test output and the reporter
+  /// should release any resources it has allocated.
+  Future close() => _engine.close();
+
+  /// 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}) {
+    // Print nothing if nothing has changed since the last progress line.
+    if (_passed.length == _lastProgressPassed &&
+        _failed.length == _lastProgressFailed &&
+        message == _lastProgressMessage) {
+      return;
+    }
+
+    _lastProgressPassed = _passed.length;
+    _lastProgressFailed = _failed.length;
+    _lastProgressMessage = message;
+
+    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);
+
+    print(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')}";
+  }
+
+  /// 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;
+  }
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 6b0939fe..06bd8dff 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -63,3 +63,44 @@ List flatten(Iterable nested) {
   helper(nested);
   return result;
 }
+
+/// 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';
+}
diff --git a/lib/unittest.dart b/lib/unittest.dart
index e4f36b59..07bc46d4 100644
--- a/lib/unittest.dart
+++ b/lib/unittest.dart
@@ -13,7 +13,7 @@ import 'src/backend/invoker.dart';
 import 'src/backend/suite.dart';
 import 'src/deprecated/configuration.dart';
 import 'src/deprecated/test_case.dart';
-import 'src/runner/console_reporter.dart';
+import 'src/runner/reporter/no_io_compact.dart';
 
 export 'package:matcher/matcher.dart';
 
@@ -49,9 +49,8 @@ Declarer get _declarer {
   _globalDeclarer = new Declarer();
   scheduleMicrotask(() {
     var suite = new Suite(p.prettyUri(Uri.base), _globalDeclarer.tests);
-    // TODO(nweiz): Use a reporter that doesn't import dart:io here.
     // TODO(nweiz): Set the exit code on the VM when issue 6943 is fixed.
-    new ConsoleReporter([suite]).run();
+    new NoIoCompactReporter([suite], color: true).run();
   });
   return _globalDeclarer;
 }
diff --git a/test/console_reporter_test.dart b/test/compact_reporter_test.dart
similarity index 100%
rename from test/console_reporter_test.dart
rename to test/compact_reporter_test.dart
-- 
GitLab