Gracefully handle tests that print.

Prints from browser tests will be forwarded to the command-line, and the compact
reporter will ensure that printed lines don't interfere with its status lines.

Closes #13

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1044953004
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3de4593..5b75883 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
 * Add a `--name` (shorthand `-n`) flag to the test runner for selecting which
   test to run.
 
+* Ensure that `print()` in tests always prints on its own line.
+
+* Forward `print()`s from browser tests to the command-line reporter.
+
 * Add a missing dependency on `string_scanner`.
 
 ## 0.12.0-beta.0
diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart
index 53d9ad0..fe4d0fb 100644
--- a/lib/src/backend/invoker.dart
+++ b/lib/src/backend/invoker.dart
@@ -178,7 +178,11 @@
           // non-microtask events.
           Timer.run(_controller.completer.complete);
         });
-      }, zoneValues: {#unittest.invoker: this}, onError: handleError);
+      },
+          zoneSpecification: new ZoneSpecification(
+              print: (self, parent, zone, line) => _controller.print(line)),
+          zoneValues: {#unittest.invoker: this},
+          onError: handleError);
     });
   }
 }
diff --git a/lib/src/backend/live_test.dart b/lib/src/backend/live_test.dart
index fef0c11..28a2a8d 100644
--- a/lib/src/backend/live_test.dart
+++ b/lib/src/backend/live_test.dart
@@ -79,6 +79,9 @@
   /// will be [Chain]s.
   Stream<AsyncError> get onError;
 
+  /// A stream that emits lines printed by the test.
+  Stream<String> onPrint;
+
   /// A [Future] that completes once the test is complete.
   ///
   /// This will complete after [onStateChange] has fired, and after [onError]
diff --git a/lib/src/backend/live_test_controller.dart b/lib/src/backend/live_test_controller.dart
index 17b8bcf..a1a0d3a 100644
--- a/lib/src/backend/live_test_controller.dart
+++ b/lib/src/backend/live_test_controller.dart
@@ -32,6 +32,8 @@
 
   Stream<AsyncError> get onError => _controller._onErrorController.stream;
 
+  Stream<String> get onPrint => _controller._onPrintController.stream;
+
   Future get onComplete => _controller.completer.future;
 
   Future run() => _controller._run();
@@ -76,10 +78,24 @@
   var _state = const State(Status.pending, Result.success);
 
   /// The controller for [LiveTest.onStateChange].
-  final _onStateChangeController = new StreamController<State>.broadcast();
+  ///
+  /// This is synchronous to ensure that events are well-ordered across multiple
+  /// streams.
+  final _onStateChangeController = new StreamController<State>
+      .broadcast(sync: true);
 
   /// The controller for [LiveTest.onError].
-  final _onErrorController = new StreamController<AsyncError>.broadcast();
+  ///
+  /// This is synchronous to ensure that events are well-ordered across multiple
+  /// streams.
+  final _onErrorController = new StreamController<AsyncError>
+      .broadcast(sync: true);
+
+  /// The controller for [LiveTest.onPrint].
+  ///
+  /// This is synchronous to ensure that events are well-ordered across multiple
+  /// streams.
+  final _onPrintController = new StreamController<String>.broadcast(sync: true);
 
   /// The completer for [LiveTest.onComplete];
   final completer = new Completer();
@@ -130,6 +146,9 @@
     _onStateChangeController.add(newState);
   }
 
+  /// Emits a line printed by the test over [LiveTest.onPrint].
+  void print(String line) => _onPrintController.add(line);
+
   /// A wrapper for [_onRun] that ensures that it follows the guarantees for
   /// [LiveTest.run].
   Future _run() {
diff --git a/lib/src/runner/browser/iframe_listener.dart b/lib/src/runner/browser/iframe_listener.dart
index 49ab7d9..9501959 100644
--- a/lib/src/runner/browser/iframe_listener.dart
+++ b/lib/src/runner/browser/iframe_listener.dart
@@ -159,6 +159,11 @@
       });
     });
 
+    liveTest.onPrint.listen((line) {
+      print(line);
+      channel.sink.add({"type": "print", "line": line});
+    });
+
     liveTest.run().then((_) => channel.sink.add({"type": "complete"}));
   }
 }
diff --git a/lib/src/runner/browser/iframe_test.dart b/lib/src/runner/browser/iframe_test.dart
index fa7b023..81c2a23 100644
--- a/lib/src/runner/browser/iframe_test.dart
+++ b/lib/src/runner/browser/iframe_test.dart
@@ -43,6 +43,8 @@
               new State(
                   new Status.parse(message['status']),
                   new Result.parse(message['result'])));
+        } else if (message['type'] == 'print') {
+          controller.print(message['line']);
         } else {
           assert(message['type'] == 'complete');
           controller.completer.complete();
diff --git a/lib/src/runner/reporter/compact.dart b/lib/src/runner/reporter/compact.dart
index 0a75d7c..49817ac 100644
--- a/lib/src/runner/reporter/compact.dart
+++ b/lib/src/runner/reporter/compact.dart
@@ -72,6 +72,8 @@
         _green = color ? '\u001b[32m' : '',
         _red = color ? '\u001b[31m' : '',
         _noColor = color ? '\u001b[0m' : '' {
+    // Whether a newline has been printed since the last progress line.
+    var printedNewline = false;
     _engine.onTestStarted.listen((liveTest) {
       _progressLine(_description(liveTest));
       liveTest.onStateChange.listen((state) {
@@ -83,16 +85,27 @@
           _failed.add(liveTest);
         }
         _progressLine(_description(liveTest));
+        printedNewline = false;
       });
 
       liveTest.onError.listen((error) {
         if (liveTest.state.status != Status.complete) return;
 
         _progressLine(_description(liveTest));
-        print('');
+        if (!printedNewline) print('');
+        printedNewline = true;
+
         print(indent(error.error.toString()));
         print(indent(terseChain(error.stackTrace).toString()));
       });
+
+      liveTest.onPrint.listen((line) {
+        _progressLine(_description(liveTest));
+        if (!printedNewline) print('');
+        printedNewline = true;
+
+        print(line);
+      });
     });
   }
 
@@ -134,12 +147,12 @@
   /// [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}) {
+  bool _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;
+      return false;
     }
 
     _lastProgressPassed = _passed.length;
@@ -180,6 +193,7 @@
     length = buffer.length - nonVisible - _noColor.length;
     buffer.write(' ' * (_lineLength - length));
     stdout.write(buffer.toString());
+    return true;
   }
 
   /// Returns a representation of [duration] as `MM:SS`.
diff --git a/lib/src/runner/reporter/no_io_compact.dart b/lib/src/runner/reporter/no_io_compact.dart
index 91dae81..3afef34 100644
--- a/lib/src/runner/reporter/no_io_compact.dart
+++ b/lib/src/runner/reporter/no_io_compact.dart
@@ -94,6 +94,11 @@
         print(indent(error.error.toString()));
         print(indent(terseChain(error.stackTrace).toString()));
       });
+
+      liveTest.onPrint.listen((line) {
+        _progressLine(_description(liveTest));
+        print(line);
+      });
     });
   }
 
diff --git a/lib/src/runner/vm/isolate_listener.dart b/lib/src/runner/vm/isolate_listener.dart
index 352490f..efaaea4 100644
--- a/lib/src/runner/vm/isolate_listener.dart
+++ b/lib/src/runner/vm/isolate_listener.dart
@@ -113,6 +113,9 @@
       });
     });
 
+    liveTest.onPrint.listen((line) =>
+        sendPort.send({"type": "print", "line": line}));
+
     liveTest.run().then((_) => sendPort.send({"type": "complete"}));
   }
 }
diff --git a/lib/src/runner/vm/isolate_test.dart b/lib/src/runner/vm/isolate_test.dart
index 76f8f77..af99a25 100644
--- a/lib/src/runner/vm/isolate_test.dart
+++ b/lib/src/runner/vm/isolate_test.dart
@@ -46,6 +46,8 @@
               new State(
                   new Status.parse(message['status']),
                   new Result.parse(message['result'])));
+        } else if (message['type'] == 'print') {
+          controller.print(message['line']);
         } else {
           assert(message['type'] == 'complete');
           controller.completer.complete();
diff --git a/pubspec.yaml b/pubspec.yaml
index 9ef9b14..4003429 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: unittest
-version: 0.12.0-dev
+version: 0.12.0-beta.1
 author: Dart Team <misc@dartlang.org>
 description: A library for writing dart unit tests.
 homepage: https://github.com/dart-lang/unittest
diff --git a/test/backend/invoker_test.dart b/test/backend/invoker_test.dart
index 6cbe20d..7ba7c77 100644
--- a/test/backend/invoker_test.dart
+++ b/test/backend/invoker_test.dart
@@ -493,6 +493,20 @@
       async.elapse(new Duration(seconds: 30));
     });
   });
+
+  test("a test's prints are captured and reported", () {
+    expect(() {
+      var liveTest = _localTest(() {
+        print("Hello,");
+        return new Future(() => print("world!"));
+      }).load(suite);
+
+      expect(liveTest.onPrint.take(2).toList(),
+          completion(equals(["Hello,", "world!"])));
+
+      return liveTest.run();
+    }, prints(isEmpty));
+  });
 }
 
 LocalTest _localTest(body(), {tearDown()}) =>
diff --git a/test/runner/browser/runner_test.dart b/test/runner/browser/runner_test.dart
index bf90243..2627a13 100644
--- a/test/runner/browser/runner_test.dart
+++ b/test/runner/browser/runner_test.dart
@@ -157,6 +157,25 @@
       expect(result.exitCode, equals(1));
     });
   });
+
+  test("forwards prints from the browser test", () {
+    new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'dart:async';
+
+import 'package:unittest/unittest.dart';
+
+void main() {
+  test("test", () {
+    print("Hello,");
+    return new Future(() => print("world!"));
+  });
+}
+""");
+
+    var result = _runUnittest(["-p", "chrome", "test.dart"]);
+    expect(result.stdout, contains("Hello,\nworld!\n"));
+    expect(result.exitCode, equals(0));
+  });
 }
 
 ProcessResult _runUnittest(List<String> args) =>
diff --git a/test/runner/compact_reporter_test.dart b/test/runner/compact_reporter_test.dart
index f672971..35c5079 100644
--- a/test/runner/compact_reporter_test.dart
+++ b/test/runner/compact_reporter_test.dart
@@ -110,14 +110,12 @@
           dart:async       Future.Future.microtask
           test.dart 10:15  main.<fn>
 
-
           second error
           test.dart 11:38  main.<fn>.<fn>
           ===== asynchronous gap ===========================
           dart:async       Future.Future.microtask
           test.dart 11:15  main.<fn>
 
-
           third error
           test.dart 12:38  main.<fn>.<fn>
           ===== asynchronous gap ===========================
@@ -129,6 +127,109 @@
         +1 -1: wait
         +1 -1: Some tests failed.""");
   });
+
+  group("print:", () {
+    test("handles multiple prints", () {
+      _expectReport("""
+        test('test', () {
+          print("one");
+          print("two");
+          print("three");
+          print("four");
+        });""",
+        """
+        +0: test
+        one
+        two
+        three
+        four
+
+        +1: test
+        +1: All tests passed!""");
+    });
+
+    test("handles a print after the test completes", () {
+      _expectReport("""
+        // This completer ensures that the test isolate isn't killed until all
+        // prints have happened.
+        var testDone = new Completer();
+        var waitStarted = new Completer();
+        test('test', () {
+          waitStarted.future.then((_) {
+            new Future(() => print("one"));
+            new Future(() => print("two"));
+            new Future(() => print("three"));
+            new Future(() => print("four"));
+            new Future(testDone.complete);
+          });
+        });
+
+        test('wait', () {
+          waitStarted.complete();
+          return testDone.future;
+        });""", """
+        +0: test
+        +1: test
+        +1: wait
+        +1: test
+        one
+        two
+        three
+        four
+
+        +2: wait
+        +2: All tests passed!""");
+    });
+
+    test("interleaves prints and errors", () {
+      _expectReport("""
+        // This completer ensures that the test isolate isn't killed until all
+        // prints have happened.
+        var completer = new 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
+          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:28  main.<fn>
+
+        five
+        six
+
+        +0 -1: wait
+        +1 -1: wait
+        +1 -1: Some tests failed.""");
+    });
+  });
 }
 
 void _expectReport(String tests, String expected, {List<String> args}) {
diff --git a/test/runner/isolate_listener_test.dart b/test/runner/isolate_listener_test.dart
index 184c86d..06d9244 100644
--- a/test/runner/isolate_listener_test.dart
+++ b/test/runner/isolate_listener_test.dart
@@ -262,6 +262,15 @@
       });
     });
   });
+
+  test("forwards a test's prints", () {
+    return _isolateTest(_printTest).then((liveTest) {
+      expect(liveTest.onPrint.take(2).toList(),
+          completion(equals(["Hello,", "world!"])));
+
+      return liveTest.run();
+    });
+  });
 }
 
 /// Loads the first test defined in [entryPoint] in another isolate.
@@ -380,3 +389,14 @@
     });
   });
 }
+
+/// An isolate entrypoint that defines a test that prints twice.
+void _printTest(SendPort sendPort) {
+  IsolateListener.start(sendPort, () => () {
+    test("prints", () {
+      print("Hello,");
+      return new Future(() => print("world!"));
+    });
+  });
+}
+