From 533b58ce1de2a3a7fe31635a709b42e88e4588c1 Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Thu, 19 Feb 2015 12:01:31 -0800
Subject: [PATCH] Add a test runner executable.

This is still extremely bare-bones and won't work with tests that actually
import "package:unittest/unittest.dart", but it's something.

R=kevmoo@google.com
See #2

Review URL: https://codereview.chromium.org//933083002
---
 .status                         |   1 +
 bin/unittest.dart               | 110 +++++++++++++++++
 lib/src/console_reporter.dart   |  38 +++++-
 lib/src/engine.dart             |   5 +
 lib/src/exit_codes.dart         |  58 +++++++++
 lib/src/invoker.dart            |   5 +-
 lib/src/load_exception.dart     |  42 +++++++
 lib/src/loader.dart             |  28 +++--
 lib/src/remote_exception.dart   |  55 ++++++---
 lib/src/utils.dart              |  30 +++++
 lib/src/vm_listener.dart        |  50 +++++++-
 pubspec.yaml                    |   3 +-
 test/console_reporter_test.dart | 179 ++++++++++++++++++++++++++++
 test/io.dart                    |  16 ++-
 test/loader_test.dart           |   2 +-
 test/runner_test.dart           | 203 ++++++++++++++++++++++++++++++++
 test/utils.dart                 |   6 +
 test/vm_listener_test.dart      |  89 ++++++++++++--
 18 files changed, 867 insertions(+), 53 deletions(-)
 create mode 100644 bin/unittest.dart
 create mode 100644 lib/src/exit_codes.dart
 create mode 100644 lib/src/load_exception.dart
 create mode 100644 test/console_reporter_test.dart
 create mode 100644 test/runner_test.dart

diff --git a/.status b/.status
index 0d5889ef..b1e4dbea 100644
--- a/.status
+++ b/.status
@@ -26,6 +26,7 @@ lib/*/*/*/*: SkipByDesign
 # dart:io-specific tests.
 [ $browser ]
 test/loader_test: SkipByDesign
+test/runner_test: SkipByDesign
 test/vm_listener_test: SkipByDesign
 
 [ $runtime == safari ]
diff --git a/bin/unittest.dart b/bin/unittest.dart
new file mode 100644
index 00000000..32379360
--- /dev/null
+++ b/bin/unittest.dart
@@ -0,0 +1,110 @@
+// 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.unittest;
+
+import 'dart:async';
+import 'dart:io';
+import 'dart:isolate';
+
+import 'package:args/args.dart';
+import 'package:stack_trace/stack_trace.dart';
+
+import 'package:unittest/src/console_reporter.dart';
+import 'package:unittest/src/exit_codes.dart' as exit_codes;
+import 'package:unittest/src/load_exception.dart';
+import 'package:unittest/src/loader.dart';
+import 'package:unittest/src/utils.dart';
+
+/// The argument parser used to parse the executable arguments.
+final _parser = new ArgParser();
+
+void main(List<String> args) {
+  _parser.addFlag("help", abbr: "h", negatable: false,
+      help: "Shows this usage information.");
+  _parser.addOption("package-root", hide: true);
+
+  var options;
+  try {
+    options = _parser.parse(args);
+  } on FormatException catch (error) {
+    _printUsage(error.message);
+    exitCode = exit_codes.usage;
+    return;
+  }
+
+  if (options["help"]) {
+    _printUsage();
+    return;
+  }
+
+  var loader = new Loader(packageRoot: options["package-root"]);
+  new Future.sync(() {
+    var paths = options.rest;
+    if (paths.isEmpty) {
+      if (!new Directory("test").existsSync()) {
+        throw new LoadException("test",
+            "No test files were passed and the default directory doesn't "
+                "exist.");
+      }
+      paths = ["test"];
+    }
+
+    return Future.wait(paths.map((path) {
+      if (new Directory(path).existsSync()) return loader.loadDir(path);
+      if (new File(path).existsSync()) return loader.loadFile(path);
+      throw new LoadException(path, 'Does not exist.');
+    }));
+  }).then((suites) {
+    var reporter = new ConsoleReporter(flatten(suites));
+    return reporter.run().then((success) {
+      exitCode = success ? 0 : 1;
+    }).whenComplete(() => reporter.close());
+  }).catchError((error, stackTrace) {
+    if (error is LoadException) {
+      // TODO(nweiz): color this message?
+      stderr.writeln(getErrorMessage(error));
+
+      // Only print stack traces for load errors that come from the user's 
+      if (error.innerError is! IOException &&
+          error.innerError is! IsolateSpawnException &&
+          error.innerError is! String) {
+        stderr.write(terseChain(stackTrace));
+      }
+
+      exitCode = error.innerError is IOException
+          ? exit_codes.io
+          : exit_codes.data;
+    } else {
+      stderr.writeln(getErrorMessage(error));
+      stderr.writeln(new Trace.from(stackTrace).terse);
+      stderr.writeln(
+          "This is an unexpected error. Please file an issue at "
+              "http://github.com/dart-lang/unittest\n"
+          "with the stack trace and instructions for reproducing the error.");
+      exitCode = exit_codes.software;
+    }
+  }).whenComplete(() => loader.close());
+}
+
+/// Print usage information for this command.
+///
+/// If [error] is passed, it's used in place of the usage message and the whole
+/// thing is printed to stderr instead of stdout.
+void _printUsage([String error]) {
+  var output = stdout;
+
+  var message = "Runs tests in this package.";
+  if (error != null) {
+    message = error;
+    output = stderr;
+  }
+
+  output.write("""$message
+
+Usage: pub run unittest:unittest [files or directories...]
+
+${_parser.usage}
+""");
+}
diff --git a/lib/src/console_reporter.dart b/lib/src/console_reporter.dart
index 3faa1b47..7d208e0c 100644
--- a/lib/src/console_reporter.dart
+++ b/lib/src/console_reporter.dart
@@ -49,6 +49,15 @@ class ConsoleReporter {
   /// 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 [ConsoleReporter] that will run all tests in [suites].
   ConsoleReporter(Iterable<Suite> suites)
       : _multipleSuites = suites.length > 1,
@@ -70,11 +79,10 @@ class ConsoleReporter {
       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}"));
+        print(indent(error.error.toString()));
+        print(indent(terseChain(error.stackTrace).toString()));
       });
     });
   }
@@ -89,11 +97,14 @@ class ConsoleReporter {
           "once.");
     }
 
+    if (_engine.liveTests.isEmpty) {
+      print("No tests ran.");
+      return new Future.value(true);
+    }
+
     _stopwatch.start();
     return _engine.run().then((success) {
-      if (_engine.liveTests.isEmpty) {
-        print("\nNo tests ran.");
-      } else if (success) {
+      if (success) {
         _progressLine("All tests passed!");
         print('');
       } else {
@@ -105,12 +116,27 @@ class ConsoleReporter {
     });
   }
 
+  /// 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();
diff --git a/lib/src/engine.dart b/lib/src/engine.dart
index 7bd69588..5e2b6508 100644
--- a/lib/src/engine.dart
+++ b/lib/src/engine.dart
@@ -64,4 +64,9 @@ class Engine {
     }).then((_) =>
         liveTests.every((liveTest) => liveTest.state.result == Result.success));
   }
+
+  /// Signals that the caller is done paying attention to test results and the
+  /// engine should release any resources it has allocated.
+  Future close() =>
+      Future.wait(liveTests.map((liveTest) => liveTest.close()));
 }
diff --git a/lib/src/exit_codes.dart b/lib/src/exit_codes.dart
new file mode 100644
index 00000000..ce39d1e3
--- /dev/null
+++ b/lib/src/exit_codes.dart
@@ -0,0 +1,58 @@
+// 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.
+
+/// Exit code constants.
+///
+/// From [the BSD sysexits manpage][manpage]. Not every constant here is used.
+///
+/// [manpage]: http://www.freebsd.org/cgi/man.cgi?query=sysexits
+library unittest.exit_codes;
+
+/// The command completely successfully.
+const success = 0;
+
+/// The command was used incorrectly.
+const usage = 64;
+
+/// The input data was incorrect.
+const data = 65;
+
+/// An input file did not exist or was unreadable.
+const noInput = 66;
+
+/// The user specified did not exist.
+const noUser = 67;
+
+/// The host specified did not exist.
+const noHost = 68;
+
+/// A service is unavailable.
+const unavailable = 69;
+
+/// An internal software error has been detected.
+const software = 70;
+
+/// An operating system error has been detected.
+const os = 71;
+
+/// Some system file did not exist or was unreadable.
+const osFile = 72;
+
+/// A user-specified output file cannot be created.
+const cantCreate = 73;
+
+/// An error occurred while doing I/O on some file.
+const io = 74;
+
+/// Temporary failure, indicating something that is not really an error.
+const tempFail = 75;
+
+/// The remote system returned something invalid during a protocol exchange.
+const protocol = 76;
+
+/// The user did not have sufficient permissions.
+const noPerm = 77;
+
+/// Something was unconfigured or mis-configured.
+const config = 78;
diff --git a/lib/src/invoker.dart b/lib/src/invoker.dart
index 4d14b48d..edc6d622 100644
--- a/lib/src/invoker.dart
+++ b/lib/src/invoker.dart
@@ -172,7 +172,10 @@ class Invoker {
           timer.cancel();
           _controller.setState(
               new State(Status.complete, liveTest.state.result));
-          _controller.completer.complete();
+
+          // Use [Timer.run] here to avoid starving the DOM or other
+          // non-microtask events.
+          Timer.run(_controller.completer.complete);
         });
       }, zoneValues: {#unittest.invoker: this}, onError: handleError);
     });
diff --git a/lib/src/load_exception.dart b/lib/src/load_exception.dart
new file mode 100644
index 00000000..8c6dff0d
--- /dev/null
+++ b/lib/src/load_exception.dart
@@ -0,0 +1,42 @@
+// 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.load_exception;
+
+import 'dart:isolate';
+
+import 'package:path/path.dart' as p;
+
+import 'utils.dart';
+
+class LoadException implements Exception {
+  final String path;
+
+  final innerError;
+
+  LoadException(this.path, this.innerError);
+
+  String toString() {
+    var buffer = new StringBuffer('Failed to load "$path":');
+
+    var innerString = getErrorMessage(innerError);
+    if (innerError is IsolateSpawnException) {
+      // If this is a parse error, get rid of the noisy preamble.
+      innerString = innerString
+          .replaceFirst("'${p.toUri(p.absolute(path))}': error: ", "");
+
+      // If this is a file system error, get rid of both the preamble and the
+      // useless stack trace.
+      innerString = innerString.replaceFirst(
+          "Unhandled exception:\n"
+          "Uncaught Error: Load Error: FileSystemException: ",
+          "");
+      innerString = innerString.split("Stack Trace:\n").first.trim();
+    }
+
+    buffer.write(innerString.contains("\n") ? "\n" : " ");
+    buffer.write(innerString);
+    return buffer.toString();
+  }
+}
diff --git a/lib/src/loader.dart b/lib/src/loader.dart
index e5b843c5..2df9bfd1 100644
--- a/lib/src/loader.dart
+++ b/lib/src/loader.dart
@@ -12,6 +12,8 @@ import 'package:path/path.dart' as p;
 
 import 'dart.dart';
 import 'isolate_test.dart';
+import 'load_exception.dart';
+import 'remote_exception.dart';
 import 'suite.dart';
 
 /// A class for finding test files and loading them into a runnable form.
@@ -49,9 +51,7 @@ class Loader {
 
   /// Loads a test suite from the file at [path].
   ///
-  /// This wil throw a [FileSystemException] if there's no `packages/` directory
-  /// available for [path]. Any other load error will cause an
-  /// [IsolateSpawnException] or a [RemoteException].
+  /// This will throw a [LoadException] if the file fails to load.
   Future<Suite> loadFile(String path) {
     // TODO(nweiz): Support browser tests.
     var packageRoot = _packageRoot == null
@@ -59,7 +59,7 @@ class Loader {
         : _packageRoot;
 
     if (!new Directory(packageRoot).existsSync()) {
-      throw new FileSystemException("Directory $packageRoot does not exist.");
+      throw new LoadException(path, "Directory $packageRoot does not exist.");
     }
 
     var receivePort = new ReceivePort();
@@ -70,15 +70,27 @@ import "${p.toUri(p.absolute(path))}" as test;
 
 void main(_, Map message) {
   var sendPort = message['reply'];
-  VmListener.start(sendPort, test.main);
+  VmListener.start(sendPort, () => test.main);
 }
 ''', {
       'reply': receivePort.sendPort
-    }, packageRoot: packageRoot).then((isolate) {
+    }, packageRoot: packageRoot).catchError((error, stackTrace) {
+      receivePort.close();
+      return new Future.error(new LoadException(path, error), stackTrace);
+    }).then((isolate) {
       _isolates.add(isolate);
       return receivePort.first;
-    }).then((tests) {
-      return new Suite(path, tests.map((test) {
+    }).then((response) {
+      if (response["type"] == "loadException") {
+        return new Future.error(new LoadException(path, response["message"]));
+      } else if (response["type"] == "error") {
+        var asyncError = RemoteException.deserialize(response["error"]);
+        return new Future.error(
+            new LoadException(path, asyncError.error),
+            asyncError.stackTrace);
+      }
+
+      return new Suite(path, response["tests"].map((test) {
         return new IsolateTest(test['name'], test['sendPort']);
       }));
     });
diff --git a/lib/src/remote_exception.dart b/lib/src/remote_exception.dart
index ba62cf71..683b19a1 100644
--- a/lib/src/remote_exception.dart
+++ b/lib/src/remote_exception.dart
@@ -5,6 +5,7 @@
 library unittest.remote_exception;
 
 import 'dart:async';
+import 'dart:isolate';
 
 import 'package:stack_trace/stack_trace.dart';
 
@@ -43,10 +44,20 @@ class RemoteException implements Exception {
       }
     }
 
+    // It's possible (although unlikely) for a user-defined class to have
+    // multiple of these supertypes. That's fine, though, since we only care
+    // about core-library-raised IsolateSpawnExceptions anyway.
+    var supertype;
+    if (error is TestFailure) {
+      supertype = 'TestFailure';
+    } else if (error is IsolateSpawnException) {
+      supertype = 'IsolateSpawnException';
+    }
+
     return {
       'message': message,
       'type': error.runtimeType.toString(),
-      'isTestFailure': error is TestFailure,
+      'supertype': supertype,
       'toString': error.toString(),
       'stackChain': new Chain.forTrace(stackTrace).toString()
     };
@@ -57,20 +68,25 @@ class RemoteException implements Exception {
   /// The returned [AsyncError] is guaranteed to have a [RemoteException] as its
   /// error and a [Chain] as its stack trace.
   static AsyncError deserialize(serialized) {
-    var exception;
-    if (serialized['isTestFailure']) {
-      exception = new RemoteTestFailure._(
-          serialized['message'],
-          serialized['type'],
-          serialized['toString']);
-    } else {
-      exception = new RemoteException._(
-          serialized['message'],
-          serialized['type'],
-          serialized['toString']);
-    }
+    return new AsyncError(
+        _deserializeException(serialized),
+        new Chain.parse(serialized['stackChain']));
+  }
 
-    return new AsyncError(exception, new Chain.parse(serialized['stackChain']));
+  /// Deserializes the exception portion of [serialized].
+  static RemoteException _deserializeException(serialized) {
+    var message = serialized['message'];
+    var type = serialized['type'];
+    var toString = serialized['toString'];
+
+    switch (serialized['supertype']) {
+      case 'TestFailure':
+        return new _RemoteTestFailure(message, type, toString);
+      case 'IsolateSpawnException':
+        return new _RemoteIsolateSpawnException(message, type, toString);
+      default:
+        return new RemoteException._(message, type, toString);
+    }
   }
 
   RemoteException._(this.message, this.type, this._toString);
@@ -82,7 +98,14 @@ class RemoteException implements Exception {
 ///
 /// It's important to preserve [TestFailure]-ness, because tests have different
 /// results depending on whether an exception was a failure or an error.
-class RemoteTestFailure extends RemoteException implements TestFailure {
-  RemoteTestFailure._(String message, String type, String toString)
+class _RemoteTestFailure extends RemoteException implements TestFailure {
+  _RemoteTestFailure(String message, String type, String toString)
+      : super._(message, type, toString);
+}
+
+/// A subclass of [RemoteException] that implements [IsolateSpawnException].
+class _RemoteIsolateSpawnException extends RemoteException
+    implements IsolateSpawnException {
+  _RemoteIsolateSpawnException(String message, String type, String toString)
       : super._(message, type, toString);
 }
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index da219276..5c12593e 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -13,6 +13,17 @@ import 'package:stack_trace/stack_trace.dart';
 /// The return type should only ever by [Future] or void.
 typedef AsyncFunction();
 
+/// A regular expression to match the exception prefix that some exceptions'
+/// [Object.toString] values contain.
+final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): ');
+
+/// Get a string description of an exception.
+///
+/// Many exceptions include the exception class name at the beginning of their
+/// [toString], so we remove that if it exists.
+String getErrorMessage(error) =>
+  error.toString().replaceFirst(_exceptionPrefix, '');
+
 /// Indent each line in [str] by two spaces.
 String indent(String str) =>
     str.replaceAll(new RegExp("^", multiLine: true), "  ");
@@ -34,6 +45,25 @@ class Pair<E, F> {
   int get hashCode => first.hashCode ^ last.hashCode;
 }
 
+/// A regular expression matching the path to a temporary file used to start an
+/// isolate.
+///
+/// These paths aren't relevant and are removed from stack traces.
+final _isolatePath =
+    new RegExp(r"/unittest_[A-Za-z0-9]{6}/runInIsolate\.dart$");
+
+/// Returns [stackTrace] converted to a [Chain] with all irrelevant frames
+/// folded together.
+Chain terseChain(StackTrace stackTrace) {
+  return new Chain.forTrace(stackTrace).foldFrames((frame) {
+    if (frame.package == 'unittest') return true;
+
+    // Filter out frames from our isolate bootstrap as well.
+    if (frame.uri.scheme != 'file') return false;
+    return frame.uri.path.contains(_isolatePath);
+  }, terse: true);
+}
+
 /// Returns a Trace object from a StackTrace object or a String, or the
 /// unchanged input if formatStacks is false;
 Trace getTrace(stack, bool formatStacks, bool filterStacks) {
diff --git a/lib/src/vm_listener.dart b/lib/src/vm_listener.dart
index 7deb0b42..2a35f221 100644
--- a/lib/src/vm_listener.dart
+++ b/lib/src/vm_listener.dart
@@ -11,6 +11,7 @@ import 'declarer.dart';
 import 'remote_exception.dart';
 import 'suite.dart';
 import 'test.dart';
+import 'utils.dart';
 
 /// A class that runs tests in a separate isolate and communicates the results
 /// back to the main isolate.
@@ -18,18 +19,54 @@ class VmListener {
   /// The test suite to run.
   final Suite _suite;
 
-  /// Extracts metadata about all the tests in [main] and sends information
-  /// about them over [sendPort].
+  /// Extracts metadata about all the tests in the function returned by
+  /// [getMain] and sends information about them over [sendPort].
+  ///
+  /// The main function is wrapped in a closure so that we can handle it being
+  /// undefined here rather than in the generated code.
   ///
   /// Once that's done, this starts listening for commands about which tests to
   /// run.
-  static void start(SendPort sendPort, main()) {
+  static void start(SendPort sendPort, Function getMain()) {
+    var main;
+    try {
+      main = getMain();
+    } on NoSuchMethodError catch (_) {
+      _sendLoadException(sendPort, "No top-level main() function defined.");
+      return;
+    }
+
+    if (main is! Function) {
+      _sendLoadException(sendPort, "Top-level main getter is not a function.");
+      return;
+    } else if (main is! AsyncFunction) {
+      _sendLoadException(
+          sendPort, "Top-level main() function takes arguments.");
+      return;
+    }
+
     var declarer = new Declarer();
-    runZoned(main, zoneValues: {#unittest.declarer: declarer});
+    try {
+      runZoned(main, zoneValues: {#unittest.declarer: declarer});
+    } catch (error, stackTrace) {
+      sendPort.send({
+        "type": "error",
+        "error": RemoteException.serialize(error, stackTrace)
+      });
+      return;
+    }
+
     new VmListener._(new Suite("VmListener", declarer.tests))
         ._listen(sendPort);
   }
 
+  /// Sends a message over [sendPort] indicating that the tests failed to load.
+  ///
+  /// [message] should describe the failure.
+  static void _sendLoadException(SendPort sendPort, String message) {
+    sendPort.send({"type": "loadException", "message": message});
+  }
+
   VmListener._(this._suite);
 
   /// Send information about [_suite] across [sendPort] and start listening for
@@ -47,7 +84,10 @@ class VmListener {
       });
     }
 
-    sendPort.send(tests);
+    sendPort.send({
+      "type": "success",
+      "tests": tests
+    });
   }
 
   /// Runs [test] and send the results across [sendPort].
diff --git a/pubspec.yaml b/pubspec.yaml
index b134cce7..049509e8 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -6,7 +6,8 @@ homepage: https://github.com/dart-lang/unittest
 environment:
   sdk: '>=1.0.0 <2.0.0'
 dependencies:
-  stack_trace: '>=0.9.0 <2.0.0'
+  args: '>=0.12.1 <0.13.0'
+  stack_trace: '>=1.2.0 <2.0.0'
 
   # Using the pre-release version of matcher. When published we will go back
   # using a tight version constraint to ensure that a constraint on unittest
diff --git a/test/console_reporter_test.dart b/test/console_reporter_test.dart
new file mode 100644
index 00000000..81661b7d
--- /dev/null
+++ b/test/console_reporter_test.dart
@@ -0,0 +1,179 @@
+// 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.
+
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:unittest/src/io.dart';
+import 'package:unittest/unittest.dart';
+
+import 'io.dart';
+
+String _sandbox;
+
+void main() {
+  test("reports when no tests are run", () {
+    return withTempDir((path) {
+      new File(p.join(path, "test.dart")).writeAsStringSync("void main() {}");
+      var result = runUnittest(["test.dart"], workingDirectory: path);
+      expect(result.stdout, equals("No tests ran.\n"));
+    });
+  });
+
+  test("runs several successful tests and reports when each completes", () {
+    _expectReport("""
+        declarer.test('success 1', () {});
+        declarer.test('success 2', () {});
+        declarer.test('success 3', () {});""",
+        """
+        +0: success 1
+        +1: success 1
+        +1: success 2
+        +2: success 2
+        +2: success 3
+        +3: success 3
+        +3: All tests passed!""");
+  });
+
+  test("runs several failing tests and reports when each fails", () {
+    _expectReport("""
+        declarer.test('failure 1', () => throw new TestFailure('oh no'));
+        declarer.test('failure 2', () => throw new TestFailure('oh no'));
+        declarer.test('failure 3', () => throw new TestFailure('oh no'));""",
+        """
+        +0: failure 1
+        +0 -1: failure 1
+          oh no
+          test.dart 7:42  main.<fn>
+          dart:isolate    _RawReceivePortImpl._handleMessage
+
+
+        +0 -1: failure 2
+        +0 -2: failure 2
+          oh no
+          test.dart 8:42  main.<fn>
+          dart:isolate    _RawReceivePortImpl._handleMessage
+
+
+        +0 -2: failure 3
+        +0 -3: failure 3
+          oh no
+          test.dart 9:42  main.<fn>
+          dart:isolate    _RawReceivePortImpl._handleMessage
+
+
+        +0 -3: Some tests failed.""");
+  });
+
+  test("runs failing tests along with successful tests", () {
+    _expectReport("""
+        declarer.test('failure 1', () => throw new TestFailure('oh no'));
+        declarer.test('success 1', () {});
+        declarer.test('failure 2', () => throw new TestFailure('oh no'));
+        declarer.test('success 2', () {});""",
+        """
+        +0: failure 1
+        +0 -1: failure 1
+          oh no
+          test.dart 7:42  main.<fn>
+          dart:isolate    _RawReceivePortImpl._handleMessage
+
+
+        +0 -1: success 1
+        +1 -1: success 1
+        +1 -1: failure 2
+        +1 -2: failure 2
+          oh no
+          test.dart 9:42  main.<fn>
+          dart:isolate    _RawReceivePortImpl._handleMessage
+
+
+        +1 -2: success 2
+        +2 -2: success 2
+        +2 -2: Some tests failed.""");
+  });
+
+  test("gracefully handles multiple test failures in a row", () {
+    _expectReport("""
+        // This completer ensures that the test isolate isn't killed until all
+        // errors have been thrown.
+        var completer = new Completer();
+        declarer.test('failures', () {
+          new Future.microtask(() => throw 'first error');
+          new Future.microtask(() => throw 'second error');
+          new Future.microtask(() => throw 'third error');
+          new Future.microtask(completer.complete);
+        });
+        declarer.test('wait', () => completer.future);""",
+        """
+        +0: failures
+        +0 -1: failures
+          first error
+          test.dart 11:38  main.<fn>.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+          ===== asynchronous gap ===========================
+          dart:async       Future.Future.microtask
+          test.dart 11:15  main.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+
+
+          second error
+          test.dart 12:38  main.<fn>.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+          ===== asynchronous gap ===========================
+          dart:async       Future.Future.microtask
+          test.dart 12:15  main.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+
+
+          third error
+          test.dart 13:38  main.<fn>.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+          ===== asynchronous gap ===========================
+          dart:async       Future.Future.microtask
+          test.dart 13:15  main.<fn>
+          dart:isolate     _RawReceivePortImpl._handleMessage
+
+
+        +0 -1: wait
+        +1 -1: wait
+        +1 -1: Some tests failed.""");
+  });
+}
+
+final _prefixLength = "XX:XX ".length;
+
+void _expectReport(String tests, String expected) {
+  var dart = """
+import 'dart:async';
+
+import 'package:unittest/unittest.dart';
+
+void main() {
+  var declarer = Zone.current[#unittest.declarer];
+$tests
+}
+""";
+
+  expect(withTempDir((path) {
+    new File(p.join(path, "test.dart")).writeAsStringSync(dart);
+    var result = runUnittest(["test.dart"], workingDirectory: path);
+
+    // Convert CRs into newlines, remove excess trailing whitespace, and trim
+    // off timestamps.
+    var actual = result.stdout.trim().split(new RegExp(r"[\r\n]")).map((line) {
+      if (line.startsWith("  ") || line.isEmpty) return line.trimRight();
+      return line.trim().substring(_prefixLength);
+    }).join("\n");
+
+    // Un-indent the expected string.
+    var indentation = expected.indexOf(new RegExp("[^ ]"));
+    expected = expected.split("\n").map((line) {
+      if (line.isEmpty) return line;
+      return line.substring(indentation);
+    }).join("\n");
+
+    expect(actual, equals(expected));
+  }), completes);
+}
diff --git a/test/io.dart b/test/io.dart
index 663d454c..1dbc377e 100644
--- a/test/io.dart
+++ b/test/io.dart
@@ -8,7 +8,6 @@ import 'dart:io';
 
 import 'package:path/path.dart' as p;
 import 'package:stack_trace/stack_trace.dart';
-import 'package:unittest/unittest.dart';
 
 /// The root directory of the `unittest` package.
 final String packageDir = _computePackageDir();
@@ -17,9 +16,14 @@ String _computePackageDir() {
   return p.dirname(p.dirname(p.fromUri(trace.frames.first.uri)));
 }
 
-/// Returns a matcher that matches a [FileSystemException] with the given
-/// [message].
-Matcher isFileSystemException(String message) => predicate(
-    (error) => error is FileSystemException && error.message == message,
-    'is a FileSystemException with message "$message"');
+/// Runs the unittest executable with the package root set properly.
+ProcessResult runUnittest(List<String> args, {String workingDirectory}) {
+  var allArgs = Platform.executableArguments.toList()
+     ..add(p.join(packageDir, 'bin/unittest.dart'))
+     ..add("--package-root=${p.join(packageDir, 'packages')}")
+     ..addAll(args);
 
+  // TODO(nweiz): Use ScheduledProcess once it's compatible.
+  return Process.runSync(Platform.executable, allArgs,
+      workingDirectory: workingDirectory);
+}
diff --git a/test/loader_test.dart b/test/loader_test.dart
index 95a4a8b1..47197fbf 100644
--- a/test/loader_test.dart
+++ b/test/loader_test.dart
@@ -83,7 +83,7 @@ void main() {
     test("throws a nice error if the package root doesn't exist", () {
       var loader = new Loader();
       expect(() => loader.loadFile(p.join(_sandbox, 'a_test.dart')),
-          throwsA(isFileSystemException(
+          throwsA(isLoadException(
               "Directory ${p.join(_sandbox, 'packages')} does not exist.")));
     });
   });
diff --git a/test/runner_test.dart b/test/runner_test.dart
new file mode 100644
index 00000000..2b22667f
--- /dev/null
+++ b/test/runner_test.dart
@@ -0,0 +1,203 @@
+// 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.
+
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:unittest/src/exit_codes.dart' as exit_codes;
+import 'package:unittest/unittest.dart';
+
+import 'io.dart';
+
+String _sandbox;
+
+final _success = """
+import 'dart:async';
+
+void main() {
+  var declarer = Zone.current[#unittest.declarer];
+  declarer.test("success", () {});
+}
+""";
+
+final _failure = """
+import 'dart:async';
+
+import 'package:unittest/unittest.dart';
+
+void main() {
+  var declarer = Zone.current[#unittest.declarer];
+  declarer.test("failure", () => throw new TestFailure("oh no"));
+}
+""";
+
+void main() {
+  setUp(() {
+    _sandbox = Directory.systemTemp.createTempSync('unittest_').path;
+  });
+
+  tearDown(() {
+    new Directory(_sandbox).deleteSync(recursive: true);
+  });
+
+  test("prints help information", () {
+    var result = _runUnittest(["--help"]);
+    expect(result.stdout, equals("""
+Runs tests in this package.
+
+Usage: pub run unittest:unittest [files or directories...]
+
+-h, --help    Shows this usage information.
+"""));
+    expect(result.exitCode, equals(exit_codes.success));
+  });
+
+  group("fails gracefully if", () {
+    test("an invalid option is passed", () {
+      var result = _runUnittest(["--asdf"]);
+      expect(result.stderr, equals("""
+Could not find an option named "asdf".
+
+Usage: pub run unittest:unittest [files or directories...]
+
+-h, --help    Shows this usage information.
+"""));
+      expect(result.exitCode, equals(exit_codes.usage));
+    });
+
+    test("a non-existent file is passed", () {
+      var result = _runUnittest(["file"]);
+      expect(result.stderr, equals('Failed to load "file": Does not exist.\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("the default directory doesn't exist", () {
+      var result = _runUnittest([]);
+      expect(result.stderr, equals(
+          'Failed to load "test": No test files were passed and the default '
+              'directory doesn\'t exist.\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("a test file fails to load", () {
+      var testPath = p.join(_sandbox, "test.dart");
+      new File(testPath).writeAsStringSync("invalid Dart file");
+      var result = _runUnittest(["test.dart"]);
+
+      expect(result.stderr, equals(
+          'Failed to load "${p.relative(testPath, from: _sandbox)}":\n'
+          "line 1 pos 1: unexpected token 'invalid'\n"
+          "invalid Dart file\n"
+          "^\n"));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("a test file throws", () {
+      var testPath = p.join(_sandbox, "test.dart");
+      new File(testPath).writeAsStringSync("void main() => throw 'oh no';");
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stderr, startsWith(
+          'Failed to load "${p.relative(testPath, from: _sandbox)}": oh no\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("a test file doesn't have a main defined", () {
+      var testPath = p.join(_sandbox, "test.dart");
+      new File(testPath).writeAsStringSync("void foo() {}");
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stderr, startsWith(
+          'Failed to load "${p.relative(testPath, from: _sandbox)}": No '
+              'top-level main() function defined.\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("a test file has a non-function main", () {
+      var testPath = p.join(_sandbox, "test.dart");
+      new File(testPath).writeAsStringSync("int main;");
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stderr, startsWith(
+          'Failed to load "${p.relative(testPath, from: _sandbox)}": Top-level '
+              'main getter is not a function.\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    test("a test file has a main with arguments", () {
+      var testPath = p.join(_sandbox, "test.dart");
+      new File(testPath).writeAsStringSync("void main(arg) {}");
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stderr, startsWith(
+          'Failed to load "${p.relative(testPath, from: _sandbox)}": Top-level '
+              'main() function takes arguments.\n'));
+      expect(result.exitCode, equals(exit_codes.data));
+    });
+
+    // TODO(nweiz): test what happens when a test file is unreadable once issue
+    // 15078 is fixed.
+  });
+
+  group("runs successful tests", () {
+    test("defined in a single file", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_success);
+      var result = _runUnittest(["test.dart"]);
+      expect(result.exitCode, equals(0));
+    });
+
+    test("defined in a directory", () {
+      for (var i = 0; i < 3; i++) {
+        new File(p.join(_sandbox, "${i}_test.dart"))
+            .writeAsStringSync(_success);
+      }
+
+      var result = _runUnittest(["."]);
+      expect(result.exitCode, equals(0));
+    });
+
+    test("defaulting to the test directory", () {
+      new Directory(p.join(_sandbox, "test")).createSync();
+      for (var i = 0; i < 3; i++) {
+        new File(p.join(_sandbox, "test", "${i}_test.dart"))
+            .writeAsStringSync(_success);
+      }
+
+      var result = _runUnittest([]);
+      expect(result.exitCode, equals(0));
+    });
+  });
+
+  group("runs failing tests", () {
+    test("defined in a single file", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_failure);
+      var result = _runUnittest(["test.dart"]);
+      expect(result.exitCode, equals(1));
+    });
+
+    test("defined in a directory", () {
+      for (var i = 0; i < 3; i++) {
+        new File(p.join(_sandbox, "${i}_test.dart"))
+            .writeAsStringSync(_failure);
+      }
+
+      var result = _runUnittest(["."]);
+      expect(result.exitCode, equals(1));
+    });
+
+    test("defaulting to the test directory", () {
+      new Directory(p.join(_sandbox, "test")).createSync();
+      for (var i = 0; i < 3; i++) {
+        new File(p.join(_sandbox, "test", "${i}_test.dart"))
+            .writeAsStringSync(_failure);
+      }
+
+      var result = _runUnittest([]);
+      expect(result.exitCode, equals(1));
+    });
+  });
+}
+
+ProcessResult _runUnittest(List<String> args) =>
+    runUnittest(args, workingDirectory: _sandbox);
diff --git a/test/utils.dart b/test/utils.dart
index f9b4075d..64a3da71 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -8,6 +8,7 @@ import 'dart:async';
 import 'dart:collection';
 
 import 'package:unittest/src/live_test.dart';
+import 'package:unittest/src/load_exception.dart';
 import 'package:unittest/src/remote_exception.dart';
 import 'package:unittest/src/state.dart';
 import 'package:unittest/unittest.dart';
@@ -71,6 +72,11 @@ Matcher isRemoteException(String message) => predicate(
     (error) => error is RemoteException && error.message == message,
     'is a RemoteException with message "$message"');
 
+/// Returns a matcher that matches a [LoadException] with the given [message].
+Matcher isLoadException(String message) => predicate(
+    (error) => error is LoadException && error.innerError == message,
+    'is a LoadException with message "$message"');
+
 /// Returns a [Future] that completes after pumping the event queue [times]
 /// times.
 ///
diff --git a/test/vm_listener_test.dart b/test/vm_listener_test.dart
index b626ad15..47ee330a 100644
--- a/test/vm_listener_test.dart
+++ b/test/vm_listener_test.dart
@@ -9,6 +9,7 @@ import 'package:unittest/src/declarer.dart';
 import 'package:unittest/src/invoker.dart';
 import 'package:unittest/src/isolate_test.dart';
 import 'package:unittest/src/live_test.dart';
+import 'package:unittest/src/remote_exception.dart';
 import 'package:unittest/src/state.dart';
 import 'package:unittest/src/suite.dart';
 import 'package:unittest/src/vm_listener.dart';
@@ -41,7 +42,11 @@ void main() {
   test("sends a list of available tests on startup", () {
     return _spawnIsolate(_successfulTests).then((receivePort) {
       return receivePort.first;
-    }).then((tests) {
+    }).then((response) {
+      expect(response, containsPair("type", "success"));
+      expect(response, contains("tests"));
+
+      var tests = response["tests"];
       expect(tests, hasLength(3));
       expect(tests[0], containsPair("name", "successful 1"));
       expect(tests[1], containsPair("name", "successful 2"));
@@ -49,6 +54,52 @@ void main() {
     });
   });
 
+  test("sends an error response if loading fails", () {
+    return _spawnIsolate(_loadError).then((receivePort) {
+      return receivePort.first;
+    }).then((response) {
+      expect(response, containsPair("type", "error"));
+      expect(response, contains("error"));
+
+      var error = RemoteException.deserialize(response["error"]).error;
+      expect(error.message, equals("oh no"));
+      expect(error.type, equals("String"));
+    });
+  });
+
+  test("sends an error response on a NoSuchMethodError", () {
+    return _spawnIsolate(_noSuchMethodError).then((receivePort) {
+      return receivePort.first;
+    }).then((response) {
+      expect(response, containsPair("type", "loadException"));
+      expect(response,
+          containsPair("message", "No top-level main() function defined."));
+    });
+  });
+
+  test("sends an error response on non-function main", () {
+    return _spawnIsolate(_nonFunction).then((receivePort) {
+      return receivePort.first;
+    }).then((response) {
+      expect(response, containsPair("type", "loadException"));
+      expect(response,
+          containsPair("message", "Top-level main getter is not a function."));
+    });
+  });
+
+  test("sends an error response on wrong-arity main", () {
+    return _spawnIsolate(_wrongArity).then((receivePort) {
+      return receivePort.first;
+    }).then((response) {
+      expect(response, containsPair("type", "loadException"));
+      expect(
+          response,
+          containsPair(
+              "message",
+              "Top-level main() function takes arguments."));
+    });
+  });
+
   group("in a successful test", () {
     test("the state changes from pending to running to complete", () {
       return _isolateTest(_successfulTests).then((liveTest) {
@@ -218,7 +269,9 @@ Future<LiveTest> _isolateTest(void entryPoint(SendPort sendPort)) {
   return _spawnIsolate(entryPoint).then((receivePort) {
     return receivePort.first;
   }).then((response) {
-    var testMap = response.first;
+    expect(response, containsPair("type", "success"));
+
+    var testMap = response["tests"].first;
     var test = new IsolateTest(testMap["name"], testMap["sendPort"]);
     var suite = new Suite("suite", [test]);
     _liveTest = test.load(suite);
@@ -238,9 +291,27 @@ Future<ReceivePort> _spawnIsolate(void entryPoint(SendPort sendPort)) {
   });
 }
 
+/// An isolate entrypoint that throws immediately.
+void _loadError(SendPort sendPort) =>
+    VmListener.start(sendPort, () => () => throw 'oh no');
+
+/// An isolate entrypoint that throws a NoSuchMethodError.
+void _noSuchMethodError(SendPort sendPort) {
+  return VmListener.start(sendPort, () =>
+      throw new NoSuchMethodError(null, #main, [], {}));
+}
+
+/// An isolate entrypoint that returns a non-function.
+void _nonFunction(SendPort sendPort) =>
+    VmListener.start(sendPort, () => null);
+
+/// An isolate entrypoint that returns a function with the wrong arity.
+void _wrongArity(SendPort sendPort) =>
+    VmListener.start(sendPort, () => (_) {});
+
 /// An isolate entrypoint that defines three tests that succeed.
 void _successfulTests(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("successful 1", () {});
     _declarer.test("successful 2", () {});
     _declarer.test("successful 3", () {});
@@ -249,14 +320,14 @@ void _successfulTests(SendPort sendPort) {
 
 /// An isolate entrypoint that defines a test that fails.
 void _failingTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("failure", () => throw new TestFailure('oh no'));
   });
 }
 
 /// An isolate entrypoint that defines a test that fails after succeeding.
 void _failAfterSucceedTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("fail after succeed", () {
       pumpEventQueue().then((_) {
         throw new TestFailure('oh no');
@@ -267,7 +338,7 @@ void _failAfterSucceedTest(SendPort sendPort) {
 
 /// An isolate entrypoint that defines a test that fails multiple times.
 void _multiFailTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("multiple failures", () {
       Invoker.current.addOutstandingCallback();
       new Future(() => throw new TestFailure("one"));
@@ -280,14 +351,14 @@ void _multiFailTest(SendPort sendPort) {
 
 /// An isolate entrypoint that defines a test that errors.
 void _errorTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("error", () => throw 'oh no');
   });
 }
 
 /// An isolate entrypoint that defines a test that errors after succeeding.
 void _errorAfterSucceedTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("error after succeed", () {
       pumpEventQueue().then((_) => throw 'oh no');
     });
@@ -296,7 +367,7 @@ void _errorAfterSucceedTest(SendPort sendPort) {
 
 /// An isolate entrypoint that defines a test that errors multiple times.
 void _multiErrorTest(SendPort sendPort) {
-  VmListener.start(sendPort, () {
+  VmListener.start(sendPort, () => () {
     _declarer.test("multiple errors", () {
       Invoker.current.addOutstandingCallback();
       new Future(() => throw "one");
-- 
GitLab