diff --git a/dart_test.yaml b/dart_test.yaml
index 137119b38d6abd56186134fb33d5544873ce7bad..d8024bedffc05bdb2f2a1356646949ab3f287ad9 100644
--- a/dart_test.yaml
+++ b/dart_test.yaml
@@ -20,3 +20,7 @@ tags:
   ie:
     add_tags: [dart2js]
     test_on: windows
+
+  # Tests that run pub. These tests may need to be excluded when there are local
+  # dependency_overrides.
+  pub:
diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart
index 0a0a31846fb34e740fac30b4687b26379dd9cff7..b93a801439004ff3ed0a31e5d4cbda19d77fb479 100644
--- a/lib/src/backend/invoker.dart
+++ b/lib/src/backend/invoker.dart
@@ -279,6 +279,9 @@ class Invoker {
         // handled, we can end up with [onError] callbacks firing before the
         // corresponding [onStateChange], which violates the timing
         // guarantees.
+        //
+        // Using [new Future] also avoids starving the DOM or other
+        // microtask-level events.
         new Future(_test._body)
             .then((_) => removeOutstandingCallback());
 
@@ -288,9 +291,7 @@ class Invoker {
         _controller.setState(
             new State(Status.complete, liveTest.state.result));
 
-        // Use [Timer.run] here to avoid starving the DOM or other
-        // non-microtask events.
-        Timer.run(_controller.completer.complete);
+        _controller.completer.complete();
       }, zoneValues: {
         #test.invoker: this,
         // Use the invoker as a key so that multiple invokers can have different
diff --git a/lib/src/runner/engine.dart b/lib/src/runner/engine.dart
index ca47c0998801c94ae39a7d58dacbeb066b6f033b..62a6db5b2f94ab3124b27f3c08e1c2f1fa0b5347 100644
--- a/lib/src/runner/engine.dart
+++ b/lib/src/runner/engine.dart
@@ -16,6 +16,9 @@ import '../backend/live_test.dart';
 import '../backend/live_test_controller.dart';
 import '../backend/state.dart';
 import '../backend/test.dart';
+import '../util/iterable_set.dart';
+import 'live_suite.dart';
+import 'live_suite_controller.dart';
 import 'load_suite.dart';
 import 'runner_suite.dart';
 
@@ -102,8 +105,8 @@ class Engine {
   Set<RunnerSuite> get addedSuites => new UnmodifiableSetView(_addedSuites);
   final _addedSuites = new Set<RunnerSuite>();
 
-  /// A broadcast that emits each [RunnerSuite] as it's added to the engine via
-  /// [suiteSink].
+  /// A broadcast stream that emits each [RunnerSuite] as it's added to the
+  /// engine via [suiteSink].
   ///
   /// Note that if a [LoadSuite] is added, this will only return that suite, not
   /// the suite it loads.
@@ -112,7 +115,26 @@ class Engine {
   Stream<RunnerSuite> get onSuiteAdded => _onSuiteAddedController.stream;
   final _onSuiteAddedController = new StreamController<RunnerSuite>.broadcast();
 
-  /// All the currently-known tests that have run, are running, or will run.
+  /// All the currently-known suites that have run or are running.
+  ///
+  /// These are [LiveSuite]s, representing the in-progress state of each suite
+  /// as its component tests are being run.
+  ///
+  /// Note that unlike [addedSuites], for suites that are loaded using
+  /// [LoadSuite]s, both the [LoadSuite] and the suite it loads will eventually
+  /// be in this set.
+  Set<LiveSuite> get liveSuites => new UnmodifiableSetView(_liveSuites);
+  final _liveSuites = new Set<LiveSuite>();
+
+  /// A broadcast stream that emits each [LiveSuite] as it's loaded.
+  ///
+  /// Note that unlike [onSuiteAdded], for suites that are loaded using
+  /// [LoadSuite]s, both the [LoadSuite] and the suite it loads will eventually
+  /// be emitted by this stream.
+  Stream<LiveSuite> get onSuiteStarted => _onSuiteStartedController.stream;
+  final _onSuiteStartedController = new StreamController<LiveSuite>.broadcast();
+
+  /// All the currently-known tests that have run or are running.
   ///
   /// These are [LiveTest]s, representing the in-progress state of each test.
   /// Tests that have not yet begun running are marked [Status.pending]; tests
@@ -122,26 +144,27 @@ class Engine {
   /// [skipped], [failed], and [active].
   ///
   /// [LiveTest.run] must not be called on these tests.
-  List<LiveTest> get liveTests => new UnmodifiableListView(_liveTests);
-  final _liveTests = new List<LiveTest>();
+  Set<LiveTest> get liveTests => new UnionSet.from(
+      [passed, skipped, failed, new IterableSet(active)],
+      disjoint: true);
 
   /// A stream that emits each [LiveTest] as it's about to start running.
   ///
   /// This is guaranteed to fire before [LiveTest.onStateChange] first fires.
-  Stream<LiveTest> get onTestStarted => _onTestStartedController.stream;
-  final _onTestStartedController = new StreamController<LiveTest>.broadcast();
+  Stream<LiveTest> get onTestStarted => _onTestStartedGroup.stream;
+  final _onTestStartedGroup = new StreamGroup<LiveTest>.broadcast();
 
   /// The set of tests that have completed and been marked as passing.
-  Set<LiveTest> get passed => new UnmodifiableSetView(_passed);
-  final _passed = new Set<LiveTest>();
+  Set<LiveTest> get passed => _passedGroup.set;
+  final _passedGroup = new UnionSetController<LiveTest>(disjoint: true);
 
   /// The set of tests that have completed and been marked as skipped.
-  Set<LiveTest> get skipped => new UnmodifiableSetView(_skipped);
-  final _skipped = new Set<LiveTest>();
+  Set<LiveTest> get skipped => _skippedGroup.set;
+  final _skippedGroup = new UnionSetController<LiveTest>(disjoint: true);
 
   /// The set of tests that have completed and been marked as failing or error.
-  Set<LiveTest> get failed => new UnmodifiableSetView(_failed);
-  final _failed = new Set<LiveTest>();
+  Set<LiveTest> get failed => _failedGroup.set;
+  final _failedGroup = new UnionSetController<LiveTest>(disjoint: true);
 
   /// The tests that are still running, in the order they begain running.
   List<LiveTest> get active => new UnmodifiableListView(_active);
@@ -177,6 +200,8 @@ class Engine {
             ? (concurrency == null ? 2 : concurrency * 2)
             : maxSuites) {
     _group.future.then((_) {
+      _onTestStartedGroup.close();
+      _onSuiteStartedController.close();
       if (_closedBeforeDone == null) _closedBeforeDone = false;
     }).catchError((_) {
       // Don't top-level errors. They'll be thrown via [success] anyway.
@@ -213,18 +238,23 @@ class Engine {
       _group.add(new Future.sync(() async {
         var loadResource = await _loadPool.request();
 
+        var controller;
         if (suite is LoadSuite) {
-          suite = await _addLoadSuite(suite);
-          if (suite == null) {
+          controller = await _addLoadSuite(suite);
+          if (controller == null) {
             loadResource.release();
             return;
           }
+        } else {
+          controller = new LiveSuiteController(suite);
         }
 
+        _addLiveSuite(controller.liveSuite);
+
         await _runPool.withResource(() async {
           if (_closed) return;
-          await _runGroup(suite, suite.group, []);
-          loadResource.allowRelease(() => suite.close());
+          await _runGroup(controller, controller.liveSuite.suite.group, []);
+          loadResource.allowRelease(() => controller.close());
         });
       }));
     }, onDone: () {
@@ -235,23 +265,26 @@ class Engine {
     return success;
   }
 
-  /// Runs all the entries in [entries] in sequence.
+  /// Runs all the entries in [group] in sequence.
   ///
+  /// [suiteController] is the controller fo the suite that contains [group].
   /// [parents] is a list of groups that contain [group]. It may be modified,
   /// but it's guaranteed to be in its original state once this function has
   /// finished.
-  Future _runGroup(RunnerSuite suite, Group group, List<Group> parents) async {
+  Future _runGroup(LiveSuiteController suiteController, Group group,
+      List<Group> parents) async {
     parents.add(group);
     try {
       if (group.metadata.skip) {
-        await _runLiveTest(_skippedTest(suite, group, parents));
+        await _runSkippedTest(suiteController, group, parents);
         return;
       }
 
       var setUpAllSucceeded = true;
       if (group.setUpAll != null) {
-        var liveTest = group.setUpAll.load(suite, groups: parents);
-        await _runLiveTest(liveTest, countSuccess: false);
+        var liveTest = group.setUpAll.load(suiteController.liveSuite.suite,
+            groups: parents);
+        await _runLiveTest(suiteController, liveTest, countSuccess: false);
         setUpAllSucceeded = liveTest.state.result == Result.success;
       }
 
@@ -260,12 +293,14 @@ class Engine {
           if (_closed) return;
 
           if (entry is Group) {
-            await _runGroup(suite, entry, parents);
+            await _runGroup(suiteController, entry, parents);
           } else if (entry.metadata.skip) {
-            await _runLiveTest(_skippedTest(suite, entry, parents));
+            await _runSkippedTest(suiteController, entry, parents);
           } else {
             var test = entry as Test;
-            await _runLiveTest(test.load(suite, groups: parents));
+            await _runLiveTest(
+                suiteController,
+                test.load(suiteController.liveSuite.suite, groups: parents));
           }
         }
       }
@@ -273,8 +308,9 @@ class Engine {
       // Even if we're closed or setUpAll failed, we want to run all the
       // teardowns to ensure that any state is properly cleaned up.
       if (group.tearDownAll != null) {
-        var liveTest = group.tearDownAll.load(suite, groups: parents);
-        await _runLiveTest(liveTest, countSuccess: false);
+        var liveTest = group.tearDownAll.load(suiteController.liveSuite.suite,
+            groups: parents);
+        await _runLiveTest(suiteController, liveTest, countSuccess: false);
         if (_closed) await liveTest.close();
       }
     } finally {
@@ -282,37 +318,18 @@ class Engine {
     }
   }
 
-  /// Returns a dummy [LiveTest] for a test or group marked as "skip".
-  ///
-  /// [parents] is a list of groups that contain [entry].
-  LiveTest _skippedTest(RunnerSuite suite, GroupEntry entry,
-      List<Group> parents) {
-    // The netry name will be `null` for the root group.
-    var test = new LocalTest(entry.name ?? "(suite)", entry.metadata, () {});
-
-    var controller;
-    controller = new LiveTestController(suite, test, () {
-      controller.setState(const State(Status.running, Result.success));
-      controller.setState(const State(Status.complete, Result.success));
-      controller.completer.complete();
-    }, () {}, groups: parents);
-    return controller.liveTest;
-  }
-
-  /// Runs [liveTest].
+  /// Runs [liveTest] using [suiteController].
   ///
   /// If [countSuccess] is `true` (the default), the test is put into [passed]
   /// if it succeeds. Otherwise, it's removed from [liveTests] entirely.
-  Future _runLiveTest(LiveTest liveTest, {bool countSuccess: true}) async {
-    _liveTests.add(liveTest);
+  Future _runLiveTest(LiveSuiteController suiteController, LiveTest liveTest,
+      {bool countSuccess: true}) async {
     _active.add(liveTest);
 
     // If there were no active non-load tests, the current active test would
     // have been a load test. In that case, remove it, since now we have a
     // non-load test to add.
-    if (_active.isNotEmpty && _active.first.suite is LoadSuite) {
-      _liveTests.remove(_active.removeFirst());
-    }
+    if (_active.first.suite is LoadSuite) _active.removeFirst();
 
     liveTest.onStateChange.listen((state) {
       if (state.status != Status.complete) return;
@@ -321,35 +338,45 @@ class Engine {
       // If we're out of non-load tests, surface a load test.
       if (_active.isEmpty && _activeLoadTests.isNotEmpty) {
         _active.add(_activeLoadTests.first);
-        _liveTests.add(_activeLoadTests.first);
-      }
-
-      if (state.result != Result.success) {
-        _passed.remove(liveTest);
-        _failed.add(liveTest);
-      } else if (liveTest.test.metadata.skip) {
-        _skipped.add(liveTest);
-      } else if (countSuccess) {
-        _passed.add(liveTest);
-      } else {
-        _liveTests.remove(liveTest);
       }
     });
 
-    _onTestStartedController.add(liveTest);
+    suiteController.reportLiveTest(liveTest, countSuccess: countSuccess);
 
-    // First, schedule a microtask to ensure that [onTestStarted] fires before
-    // the first [LiveTest.onStateChange] event. Once the test finishes, use
-    // [new Future] to do a coarse-grained event loop pump to avoid starving
-    // non-microtask events.
+    // Schedule a microtask to ensure that [onTestStarted] fires before the
+    // first [LiveTest.onStateChange] event.
     await new Future.microtask(liveTest.run);
+
+    // Once the test finishes, use [new Future] to do a coarse-grained event
+    // loop pump to avoid starving non-microtask events.
     await new Future(() {});
 
     if (!_restarted.contains(liveTest)) return;
-    await _runLiveTest(liveTest.copy(), countSuccess: countSuccess);
+    await _runLiveTest(suiteController, liveTest.copy(),
+        countSuccess: countSuccess);
     _restarted.remove(liveTest);
   }
 
+  /// Runs a dummy [LiveTest] for a test or group marked as "skip".
+  ///
+  /// [suiteController] is the controller for the suite that contains [entry].
+  /// [parents] is a list of groups that contain [entry].
+  Future _runSkippedTest(LiveSuiteController suiteController, GroupEntry entry,
+      List<Group> parents) {
+    // The netry name will be `null` for the root group.
+    var test = new LocalTest(entry.name ?? "(suite)", entry.metadata, () {});
+
+    var controller;
+    controller = new LiveTestController(
+        suiteController.liveSuite.suite, test, () {
+      controller.setState(const State(Status.running, Result.success));
+      controller.setState(const State(Status.complete, Result.success));
+      controller.completer.complete();
+    }, () {}, groups: parents);
+
+    return _runLiveTest(suiteController, controller.liveTest);
+  }
+
   /// Closes [liveTest] and tells the engine to re-run it once it's done
   /// running.
   ///
@@ -369,19 +396,18 @@ class Engine {
     await liveTest.close();
   }
 
-  /// Adds listeners for [suite].
+  /// Runs [suite] and returns the [LiveSuiteController] for the suite it loads.
   ///
-  /// Load suites have specific logic apart from normal test suites.
-  Future<RunnerSuite> _addLoadSuite(LoadSuite suite) async {
-    var liveTest = await suite.test.load(suite);
+  /// Returns `null` if the suite fails to load.
+  Future<LiveSuiteController> _addLoadSuite(LoadSuite suite) async {
+    var controller = new LiveSuiteController(suite);
+    _addLiveSuite(controller.liveSuite);
 
+    var liveTest = await suite.test.load(suite);
     _activeLoadTests.add(liveTest);
 
     // Only surface the load test if there are no other tests currently running.
-    if (_active.isEmpty) {
-      _liveTests.add(liveTest);
-      _active.add(liveTest);
-    }
+    if (_active.isEmpty) _active.add(liveTest);
 
     liveTest.onStateChange.listen((state) {
       if (state.status != Status.complete) return;
@@ -392,26 +418,43 @@ class Engine {
       // load test.
       if (_active.isNotEmpty && _active.first.suite == suite) {
         _active.remove(liveTest);
-        _liveTests.remove(liveTest);
-
-        if (_activeLoadTests.isNotEmpty) {
-          _active.add(_activeLoadTests.last);
-          _liveTests.add(_activeLoadTests.last);
-        }
+        if (_activeLoadTests.isNotEmpty) _active.add(_activeLoadTests.last);
       }
+    });
+
+    controller.reportLiveTest(liveTest, countSuccess: false);
+    controller.noMoreLiveTests();
+
+    // Schedule a microtask to ensure that [onTestStarted] fires before the
+    // first [LiveTest.onStateChange] event.
+    new Future.microtask(liveTest.run);
 
-      // Surface the load test if it fails so that the user can see the failure.
-      if (state.result == Result.success) return;
-      _failed.add(liveTest);
-      _liveTests.add(liveTest);
+    var innerSuite = await suite.suite;
+    if (innerSuite == null) return null;
+
+    var innerController = new LiveSuiteController(innerSuite);
+    innerController.liveSuite.onClose.then((_) {
+      // When the main suite is closed, close the load suite and its test as
+      // well. This doesn't release any resources, but it does close streams
+      // which indicates that the load test won't experience an error in the
+      // future.
+      liveTest.close();
+      controller.close();
     });
 
-    // Run the test immediately. We don't want loading to be blocked on suites
-    // that are already running.
-    _onTestStartedController.add(liveTest);
-    await liveTest.run();
+    return innerController;
+  }
+
+  /// Add [liveSuite] and the information it exposes to the engine's
+  /// informational streams and collections.
+  void _addLiveSuite(LiveSuite liveSuite) {
+    _liveSuites.add(liveSuite);
+    _onSuiteStartedController.add(liveSuite);
 
-    return suite.suite;
+    _onTestStartedGroup.add(liveSuite.onTestStarted);
+    _passedGroup.add(liveSuite.passed);
+    _skippedGroup.add(liveSuite.skipped);
+    _failedGroup.add(liveSuite.failed);
   }
 
   /// Signals that the caller is done paying attention to test results and the
diff --git a/lib/src/runner/live_suite.dart b/lib/src/runner/live_suite.dart
new file mode 100644
index 0000000000000000000000000000000000000000..d15f0da4ec90f1bd7c125b849e4eaad1a9a34d8c
--- /dev/null
+++ b/lib/src/runner/live_suite.dart
@@ -0,0 +1,88 @@
+// Copyright (c) 2016, 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:collection/collection.dart';
+
+import '../backend/live_test.dart';
+import 'runner_suite.dart';
+
+/// A view of the execution of a test suite.
+///
+/// This is distinct from [Suite] because it represents the progress of running
+/// a suite rather than the suite's contents. It provides events and collections
+/// that give the caller a view into the suite's current state.
+abstract class LiveSuite {
+  /// The suite that's being run.
+  RunnerSuite get suite;
+
+  /// Whether the suite has completed.
+  ///
+  /// Note that even if this returns `true`, the suite may still be running code
+  /// asynchronously. A suite is considered complete once all of its tests are
+  /// complete, but it's possible for a test to continue running even after it's
+  /// been marked complete—see [LiveTest.isComplete] for details.
+  ///
+  /// The [isClosed] getter can be used to determine whether the suite and its
+  /// tests are guaranteed to emit no more events.
+  bool get isComplete;
+
+  /// A [Future] that completes once the suite is complete.
+  ///
+  /// Note that even once this completes, the suite may still be running code
+  /// asynchronously. A suite is considered complete once all of its tests are
+  /// complete, but it's possible for a test to continue running even after it's
+  /// been marked complete—see [LiveTest.isComplete] for details.
+  ///
+  /// The [onComplete] future can be used to determine when the suite and its
+  /// tests are guaranteed to emit no more events.
+  Future get onComplete;
+
+  /// Whether the suite has been closed.
+  ///
+  /// If this is `true`, no code is running for the suite or any of its tests.
+  /// At this point, the caller can be sure that the suites' tests are all in
+  /// fixed states that will not change in the future.
+  bool get isClosed;
+
+  /// A [Future] that completes when the suite has been closed.
+  ///
+  /// Once this completes, no code is running for the suite or any of its tests.
+  /// At this point, the caller can be sure that the suites' tests are all in
+  /// fixed states that will not change in the future.
+  Future get onClose;
+
+  /// All the currently-known tests in this suite that have run or are running.
+  ///
+  /// This is guaranteed to contain the same tests as the union of [passed],
+  /// [skipped], [failed], and [active].
+  Set<LiveTest> get liveTests {
+    var sets = [passed, skipped, failed];
+    if (active != null) sets.add(new Set.from([active]));
+    return new UnionSet.from(sets);
+  }
+
+  /// A stream that emits each [LiveTest] in this suite as it's about to start
+  /// running.
+  ///
+  /// This is guaranteed to fire before [LiveTest.onStateChange] first fires. It
+  /// will close once all tests the user has selected are run.
+  Stream<LiveTest> get onTestStarted;
+
+  /// The set of tests in this suite that have completed and been marked as
+  /// passing.
+  Set<LiveTest> get passed;
+
+  /// The set of tests in this suite that have completed and been marked as
+  /// skipped.
+  Set<LiveTest> get skipped;
+
+  /// The set of tests in this suite that have completed and been marked as
+  /// failing or error.
+  Set<LiveTest> get failed;
+
+  /// The currently running test in this suite, or `null` if no test is running.
+  LiveTest get active;
+}
diff --git a/lib/src/runner/live_suite_controller.dart b/lib/src/runner/live_suite_controller.dart
new file mode 100644
index 0000000000000000000000000000000000000000..4cacf2b94518f28f4867ee33534c146cf948fb80
--- /dev/null
+++ b/lib/src/runner/live_suite_controller.dart
@@ -0,0 +1,155 @@
+// Copyright (c) 2016, 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:async/async.dart' hide Result;
+import 'package:collection/collection.dart';
+
+import '../backend/state.dart';
+import '../backend/live_test.dart';
+import 'live_suite.dart';
+import 'runner_suite.dart';
+
+/// An implementation of [LiveSuite] that's controlled by a
+/// [LiveSuiteController].
+class _LiveSuite extends LiveSuite {
+  final LiveSuiteController _controller;
+
+  RunnerSuite get suite => _controller._suite;
+
+  bool get isComplete => _controller._isComplete;
+
+  Future get onComplete => _controller._onCompleteGroup.future;
+
+  bool get isClosed => _controller._onCloseCompleter.isCompleted;
+
+  Future get onClose => _controller._onCloseCompleter.future;
+
+  Stream<LiveTest> get onTestStarted =>
+      _controller._onTestStartedController.stream;
+
+  Set<LiveTest> get passed => new UnmodifiableSetView(_controller._passed);
+
+  Set<LiveTest> get skipped => new UnmodifiableSetView(_controller._skipped);
+
+  Set<LiveTest> get failed => new UnmodifiableSetView(_controller._failed);
+
+  LiveTest get active => _controller._active;
+
+  _LiveSuite(this._controller);
+}
+
+/// A controller that drives a [LiveSuite].
+///
+/// This is a utility class to make it easier for [Engine] to create the
+/// [LiveSuite]s exposed by various APIs. The [LiveSuite] is accessible through
+/// [LiveSuiteController.liveSuite]. When a live test is run, it should be
+/// passed to [reportLiveTest], and once tests are finished being run for this
+/// suite, [noMoreLiveTests] should be called. Once the suite should be torn
+/// down, [close] should be called.
+class LiveSuiteController {
+  /// The [LiveSuite] controlled by [this].
+  LiveSuite get liveSuite => _liveSuite;
+  LiveSuite _liveSuite;
+
+  /// The suite that's being run.
+  final RunnerSuite _suite;
+
+  /// The future group that backs [LiveSuite.onComplete].
+  ///
+  /// This contains all the futures from tests that are run in this suite.
+  final _onCompleteGroup = new FutureGroup();
+
+  /// Whether [_onCompleteGroup]'s future has fired.
+  var _isComplete = false;
+
+  /// The completer that backs [LiveSuite.onClose].
+  ///
+  /// This is completed when the live suite is closed.
+  final _onCloseCompleter = new Completer();
+
+  /// The controller for [LiveSuite.onTestStarted].
+  final _onTestStartedController =
+      new StreamController<LiveTest>.broadcast(sync: true);
+
+  /// The set that backs [LiveTest.passed].
+  final _passed = new Set<LiveTest>();
+
+  /// The set that backs [LiveTest.skipped].
+  final _skipped = new Set<LiveTest>();
+
+  /// The set that backs [LiveTest.failed].
+  final _failed = new Set<LiveTest>();
+
+  /// The test exposed through [LiveTest.active].
+  LiveTest _active;
+
+  /// Creates a controller for a live suite representing running the tests in
+  /// [suite].
+  ///
+  /// Once this is called, the controller assumes responsibility for closing the
+  /// suite. The caller should call [LiveSuiteController.close] rather than
+  /// calling [RunnerSuite.close] directly.
+  LiveSuiteController(this._suite) {
+    _liveSuite = new _LiveSuite(this);
+
+    _onCompleteGroup.future.then((_) {
+      _isComplete = true;
+    }, onError: (_) {});
+  }
+
+  /// Reports the status of [liveTest] through [liveSuite].
+  ///
+  /// The live test is assumed to be a member of this suite. If [countSuccess]
+  /// is `true` (the default), the test is put into [passed] if it succeeds.
+  /// Otherwise, it's removed from [liveTests] entirely.
+  ///
+  /// Throws a [StateError] if called after [noMoreLiveTests].
+  void reportLiveTest(LiveTest liveTest, {bool countSuccess: true}) {
+    if (_onTestStartedController.isClosed) {
+      throw new StateError("Can't call reportLiveTest() after noMoreTests().");
+    }
+
+    assert(liveTest.suite == _suite);
+    assert(_active == null);
+
+    _active = liveTest;
+
+    liveTest.onStateChange.listen((state) {
+      if (state.status != Status.complete) return;
+      _active = null;
+
+      if (state.result != Result.success) {
+        _passed.remove(liveTest);
+        _failed.add(liveTest);
+      } else if (liveTest.test.metadata.skip) {
+        _skipped.add(liveTest);
+      } else if (countSuccess) {
+        _passed.add(liveTest);
+      }
+    });
+
+    _onTestStartedController.add(liveTest);
+
+    _onCompleteGroup.add(liveTest.onComplete);
+  }
+
+  /// Indicates that all the live tests that are going to be provided for this
+  /// suite have already been provided.
+  void noMoreLiveTests() {
+    _onTestStartedController.close();
+    _onCompleteGroup.close();
+  }
+
+  /// Closes the underlying suite.
+  Future close() => _closeMemo.runOnce(() async {
+    try {
+      await _suite.close();
+    } finally {
+      _onCloseCompleter.complete();
+    }
+  });
+  final _closeMemo = new AsyncMemoizer();
+}
diff --git a/lib/src/util/iterable_set.dart b/lib/src/util/iterable_set.dart
new file mode 100644
index 0000000000000000000000000000000000000000..5166a725a0a8253db399ca6afb1f6375f093b688
--- /dev/null
+++ b/lib/src/util/iterable_set.dart
@@ -0,0 +1,35 @@
+// Copyright (c) 2016, 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:collection';
+
+import 'package:collection/collection.dart';
+
+/// An unmodifiable [Set] view backed by an arbitrary [Iterable].
+///
+/// Note that contrary to most APIs that take iterables, this does not convert
+/// its argument to another collection before use. This means that if it's
+/// lazily-generated, that generation will happen for every operation.
+///
+/// Note also that set operations that are usually expected to be `O(1)` or
+/// `O(log(n))`, such as [contains], may be `O(n)` for many underlying iterable
+/// types. As such, this should only be used for small iterables.
+class IterableSet<E> extends SetMixin<E> with UnmodifiableSetMixin<E> {
+  /// The base iterable that set operations forward to.
+  final Iterable<E> _base;
+
+  int get length => _base.length;
+
+  Iterator<E> get iterator => _base.iterator;
+
+  /// Creates a [Set] view of [base].
+  IterableSet(this._base);
+
+  bool contains(Object element) => _base.contains(element);
+
+  E lookup(Object needle) =>
+      _base.firstWhere((element) => element == needle, orElse: () => null);
+
+  Set<E> toSet() => _base.toSet();
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index abe3c5080b63803beed2b00a48d8634d04321c56..bbe7800ca5cdb8219b84e48a4be9bf3d98039cd9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -11,7 +11,7 @@ dependencies:
   async: '^1.8.0'
   barback: '>=0.14.0 <0.16.0'
   boolean_selector: '^1.0.0'
-  collection: '^1.1.0'
+  collection: '^1.6.0'
   glob: '^1.0.0'
   http_multi_server: '>=1.0.0 <3.0.0'
   path: '^1.2.0'
diff --git a/test/io.dart b/test/io.dart
index f7ead650692c26604599182f08e457ca574c7e79..00bb3e0c12bf801fd3ab5a5e59ad6babd7bbf560 100644
--- a/test/io.dart
+++ b/test/io.dart
@@ -7,6 +7,7 @@
 library test.test.io;
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
 
 import 'package:path/path.dart' as p;
@@ -15,6 +16,7 @@ import 'package:scheduled_test/scheduled_process.dart';
 import 'package:scheduled_test/scheduled_stream.dart';
 import 'package:scheduled_test/scheduled_test.dart';
 import 'package:test/src/util/io.dart';
+import 'package:yaml/yaml.dart';
 
 /// The path to the root directory of the `test` package.
 final String packageDir = p.dirname(p.dirname(libraryPath(#test.test.io)));
diff --git a/test/runner/configuration/top_level_test.dart b/test/runner/configuration/top_level_test.dart
index 8a219513aaa70b681ec26ed37b0f3fce49f4ec18..3f1f571762dca7b4d9df3192e0fe3f953ae2a157 100644
--- a/test/runner/configuration/top_level_test.dart
+++ b/test/runner/configuration/top_level_test.dart
@@ -334,7 +334,7 @@ transformers:
     test.stdout.expect(consumeThrough(contains('+1: All tests passed!')));
     test.shouldExit(0);
     pub.kill();
-  });
+  }, tags: 'pub');
 
   test("uses the specified concurrency", () {
     d.file("dart_test.yaml", JSON.encode({
diff --git a/test/runner/json_reporter_test.dart b/test/runner/json_reporter_test.dart
index 31168fc485c0d7bf3a92f5a495bec793fbc1a660..401014d12b40c285bfca3545462e26a3474a810a 100644
--- a/test/runner/json_reporter_test.dart
+++ b/test/runner/json_reporter_test.dart
@@ -413,7 +413,7 @@ void _expectReport(String tests, List<Map> expected) {
     var stdoutLines = await test.stdoutStream().toList();
 
     expect(stdoutLines.length, equals(expected.length),
-        reason: "Expected $stdoutLines to match $expected.");
+        reason: "Expected $stdoutLines to match ${JSON.encode(expected)}.");
 
     // TODO(nweiz): validate each event against the JSON schema when
     // patefacio/json_schema#4 is merged.
diff --git a/test/runner/pub_serve_test.dart b/test/runner/pub_serve_test.dart
index f6df2536edaebe63a39027193f699ae2aa3aa621..34d277205f8131f4a670ec6c1cd4cf70abc1f9be 100644
--- a/test/runner/pub_serve_test.dart
+++ b/test/runner/pub_serve_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 @TestOn("vm")
+@Tags(const ["pub"])
 
 import 'dart:async';
 import 'dart:io';