diff --git a/lib/src/barback/build_environment.dart b/lib/src/barback/build_environment.dart
index af2eae8c906043bad774c0258d8914ed871e643a..2870349dbe90d3c77519faef7b559f9395f26d71 100644
--- a/lib/src/barback/build_environment.dart
+++ b/lib/src/barback/build_environment.dart
@@ -33,10 +33,16 @@ class BuildEnvironment {
   /// Creates a new build environment for working with the assets used by
   /// [entrypoint] and its dependencies.
   ///
-  /// Spawns an HTTP server on [hostname] and [port]. Loads all used
-  /// transformers using [mode] (including dart2js if [useDart2JS] is true).
+  /// Spawns an HTTP server for each directory in [rootDirectories]. These
+  /// servers will be on [hostname] and have ports based on [basePort].
+  /// [basePort] itself is reserved for "web/" and `basePort + 1` is reserved
+  /// for "test/"; further ports will be allocated for other root directories as
+  /// necessary. If [basePort] is zero, each server will have an ephemeral port.
   ///
-  /// Includes [buildDirectories] in the root package, as well as "lib" and
+  /// Loads all used transformers using [mode] (including dart2js if
+  /// [useDart2JS] is true).
+  ///
+  /// Includes [rootDirectories] in the root package, as well as "lib" and
   /// "asset".
   ///
   /// If [watcherType] is not [WatcherType.NONE], watches source assets for
@@ -45,17 +51,17 @@ class BuildEnvironment {
   /// Returns a [Future] that completes to the environment once the inputs,
   /// transformers, and server are loaded and ready.
   static Future<BuildEnvironment> create(Entrypoint entrypoint,
-      String hostname, int port, BarbackMode mode, WatcherType watcherType,
-      Set<String> buildDirectories,
+      String hostname, int basePort, BarbackMode mode, WatcherType watcherType,
+      Iterable<String> rootDirectories,
       {bool useDart2JS: true}) {
     return entrypoint.loadPackageGraph().then((graph) {
       var barback = new Barback(new PubPackageProvider(graph));
       barback.log.listen(_log);
 
-      return BarbackServer.bind(hostname, port, barback,
-          graph.entrypoint.root.name).then((server) {
-        var environment = new BuildEnvironment._(graph, server, mode,
-            watcherType, buildDirectories);
+      return _startServers(hostname, basePort, mode, graph, barback,
+          rootDirectories).then((servers) {
+        var environment = new BuildEnvironment._(graph, servers, mode,
+            watcherType, rootDirectories);
 
         // If the entrypoint package manually configures the dart2js
         // transformer, don't include it in the built-in transformer list.
@@ -78,11 +84,43 @@ class BuildEnvironment {
     });
   }
 
-  /// The server serving this environment's assets.
-  final BarbackServer server;
+  /// Start the [BarbackServer]s that will serve [rootDirectories].
+  static Future<List<BarbackServer>> _startServers(String hostname,
+      int basePort, BarbackMode mode, PackageGraph graph, Barback barback,
+      Iterable<String> rootDirectories) {
+    _bind(port, rootDirectory) {
+      if (basePort == 0) port = 0;
+      return BarbackServer.bind(hostname, port, barback,
+          graph.entrypoint.root.name, rootDirectory);
+    }
+
+    rootDirectories = rootDirectories.toList();
+
+    // For consistency, "web/" should always have the first available port and
+    // "test/" should always have the second. Other directories are assigned
+    // the following ports in alphabetical order.
+    var serverFutures = [];
+    if (rootDirectories.remove('web')) {
+      serverFutures.add(_bind(basePort, 'web'));
+    }
+    if (rootDirectories.remove('test')) {
+      serverFutures.add(_bind(basePort + 1, 'test'));
+    }
+
+    var i = 0;
+    for (var dir in rootDirectories) {
+      serverFutures.add(_bind(basePort + 2 + i, dir));
+      i += 1;
+    }
+
+    return Future.wait(serverFutures);
+  }
+
+  /// The servers serving this environment's assets.
+  final List<BarbackServer> servers;
 
   /// The [Barback] instance used to process assets in this environment.
-  Barback get barback => server.barback;
+  Barback get barback => servers.first.barback;
 
   /// The root package being built.
   Package get rootPackage => graph.entrypoint.root;
@@ -100,12 +138,13 @@ class BuildEnvironment {
   /// How source files should be watched.
   final WatcherType _watcherType;
 
-  /// The set of top-level directories in the entrypoint package that should be
-  /// built.
-  final Set<String> _buildDirectories;
+  /// The set of top-level directories in the entrypoint package that will be
+  /// exposed.
+  final Set<String> _rootDirectories;
 
-  BuildEnvironment._(this.graph, this.server, this.mode, this._watcherType,
-      this._buildDirectories);
+  BuildEnvironment._(this.graph, this.servers, this.mode, this._watcherType,
+      Iterable<String> rootDirectories)
+      : _rootDirectories = rootDirectories.toSet();
 
   /// Gets the built-in [Transformer]s that should be added to [package].
   ///
@@ -121,7 +160,7 @@ class BuildEnvironment {
     return _builtInTransformers;
   }
 
-  /// Creates a [BarbackServer] for this environment.
+  /// Loads the assets and transformers for this environment.
   ///
   /// This transforms and serves all library and asset files in all packages in
   /// the environment's package graph. It loads any transformer plugins defined
@@ -134,21 +173,24 @@ class BuildEnvironment {
     return _provideSources(barback).then((_) {
       var completer = new Completer();
 
-      // If any errors get emitted either by barback or by the server,
+      // If any errors get emitted either by barback or by the primary server,
       // including non-programmatic barback errors, they should take down the
       // whole program.
       var subscriptions = [
-        server.barback.errors.listen((error) {
+        barback.errors.listen((error) {
           if (error is TransformerException) error = error.error;
           if (!completer.isCompleted) {
             completer.completeError(error, new Chain.current());
           }
         }),
-        server.barback.results.listen((_) {}, onError: (error, stackTrace) {
+        barback.results.listen((_) {},
+            onError: (error, stackTrace) {
           if (completer.isCompleted) return;
           completer.completeError(error, stackTrace);
         }),
-        server.results.listen((_) {}, onError: (error, stackTrace) {
+        // We only listen to the first server here because that's the one used
+        // to initialize all the transformers during the initial load.
+        servers.first.results.listen((_) {}, onError: (error, stackTrace) {
           if (completer.isCompleted) return;
           completer.completeError(error, stackTrace);
         })
@@ -296,7 +338,7 @@ class BuildEnvironment {
     var directories = ["asset", "lib"];
 
     if (package.name == entrypoint.root.name) {
-      directories.addAll(_buildDirectories);
+      directories.addAll(_rootDirectories);
     }
 
     return directories;
diff --git a/lib/src/barback/load_all_transformers.dart b/lib/src/barback/load_all_transformers.dart
index e1ac463460e8689ae5f039280e9e2436915ac244..7ff0dc56c72f5907502b188454e9a4001780ac59 100644
--- a/lib/src/barback/load_all_transformers.dart
+++ b/lib/src/barback/load_all_transformers.dart
@@ -18,8 +18,9 @@ import '../utils.dart';
 
 /// Loads all transformers depended on by packages in [environment].
 ///
-/// This uses [environment]'s server to serve the Dart files from which
-/// transformers are loaded, then adds the transformers to `server.barback`.
+/// This uses [environment]'s primary server to serve the Dart files from which
+/// transformers are loaded, then adds the transformers to
+/// `environment.barback`.
 ///
 /// Any built-in transformers that are provided by the environment will
 /// automatically be added to the end of the root package's cascade.
diff --git a/lib/src/barback/load_transformers.dart b/lib/src/barback/load_transformers.dart
index 30aefdee9abfdfb7f65a873e5b99536546e868d5..13567edcdbb7160869936af5ed02b0104fff68fc 100644
--- a/lib/src/barback/load_transformers.dart
+++ b/lib/src/barback/load_transformers.dart
@@ -366,8 +366,11 @@ Future<Set> loadTransformers(BuildEnvironment environment, TransformerId id) {
   return id.getAssetId(environment.barback).then((assetId) {
     var path = assetId.path.replaceFirst('lib/', '');
     // TODO(nweiz): load from a "package:" URI when issue 12474 is fixed.
-    var baseUrl = baseUrlForAddress(environment.server.address,
-                                    environment.server.port);
+
+    // We could load the transformers from any server, since they all serve the
+    // packages' library files. We choose the first one arbitrarily.
+    var baseUrl = baseUrlForAddress(environment.servers.first.address,
+                                    environment.servers.first.port);
     var uri = '$baseUrl/packages/${id.package}/$path';
     var code = 'import "$uri";\n' +
         _TRANSFORMER_ISOLATE.replaceAll('<<URL_BASE>>', baseUrl);
diff --git a/lib/src/barback/server.dart b/lib/src/barback/server.dart
index 58864ca3fb7c3cde2aada90ed028e81d16fae284..f5fe5a10155b93c36129b4fe1eb447915b04241c 100644
--- a/lib/src/barback/server.dart
+++ b/lib/src/barback/server.dart
@@ -25,10 +25,14 @@ class BarbackServer {
   /// The underlying HTTP server.
   final HttpServer _server;
 
-  /// The name of the root package, from whose `web` directory root assets will
-  /// be served.
+  /// The name of the root package, from whose [rootDirectory] assets will be
+  /// served.
   final String _rootPackage;
 
+  /// The directory in [_rootPackage] which will serve as the root of this
+  /// server.
+  final String rootDirectory;
+
   /// The barback instance from which this serves assets.
   final Barback barback;
 
@@ -61,12 +65,14 @@ class BarbackServer {
   /// This server will serve assets from [barback], and use [rootPackage] as
   /// the root package.
   static Future<BarbackServer> bind(String host, int port,
-      Barback barback, String rootPackage) {
-    return Chain.track(HttpServer.bind(host, port))
-        .then((server) => new BarbackServer._(server, barback, rootPackage));
+      Barback barback, String rootPackage, String rootDirectory) {
+    return Chain.track(HttpServer.bind(host, port)).then((server) {
+      return new BarbackServer._(server, barback, rootPackage, rootDirectory);
+    });
   }
 
-  BarbackServer._(HttpServer server, this.barback, this._rootPackage)
+  BarbackServer._(HttpServer server, this.barback, this._rootPackage,
+      this.rootDirectory)
       : _server = server,
         port = server.port,
         address = server.address {
@@ -262,13 +268,13 @@ class BarbackServer {
     var id = specialUrlToId(url);
     if (id != null) return id;
 
-    // Otherwise, it's a path in current package's web directory.
+    // Otherwise, it's a path in current package's [rootDirectory].
     var parts = path.url.split(url.path);
 
     // Strip the leading "/" from the URL.
     if (parts.isNotEmpty && parts.first == "/") parts = parts.skip(1);
 
-    var relativePath = path.url.join("web", path.url.joinAll(parts));
+    var relativePath = path.url.join(rootDirectory, path.url.joinAll(parts));
     return new AssetId(_rootPackage, relativePath);
   }
 
diff --git a/lib/src/command.dart b/lib/src/command.dart
index d5eb6eabef02e3226ffb7e730a550f997fb03b39..92cafec76f5cdef166211d16c612d8950fc88bd4 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -85,7 +85,7 @@ abstract class PubCommand {
     buffer.writeln('Available ${isSubcommand ? "sub" : ""}commands:');
     for (var name in names) {
       buffer.writeln('  ${padRight(name, length)}   '
-          '${commands[name].description}');
+          '${commands[name].description.split("\n").first}');
     }
 
     return buffer.toString();
diff --git a/lib/src/command/build.dart b/lib/src/command/build.dart
index eba1a101e0f0972a4a3791904ec47eed019254a4..aee823a5317db35af6ead95d1b1d0bf4d811b0af 100644
--- a/lib/src/command/build.dart
+++ b/lib/src/command/build.dart
@@ -68,12 +68,12 @@ class BuildCommand extends PubCommand {
 
       // Show in-progress errors, but not results. Those get handled implicitly
       // by getAllAssets().
-      environment.server.barback.errors.listen((error) {
+      environment.barback.errors.listen((error) {
         log.error(log.red("Build error:\n$error"));
       });
 
       return log.progress("Building ${entrypoint.root.name}",
-          () => environment.server.barback.getAllAssets()).then((assets) {
+          () => environment.barback.getAllAssets()).then((assets) {
         // Find all of the JS entrypoints we built.
         var dart2JSEntrypoints = assets
             .where((asset) => asset.id.path.endsWith(".dart.js"))
diff --git a/lib/src/command/serve.dart b/lib/src/command/serve.dart
index a03bdd9904d967b2992395de588b146178c9a413..eb7c517ff0b0e4ff93361c4fb6d16273e7a11ee3 100644
--- a/lib/src/command/serve.dart
+++ b/lib/src/command/serve.dart
@@ -5,8 +5,10 @@
 library pub.command.serve;
 
 import 'dart:async';
+import 'dart:math' as math;
 
 import 'package:barback/barback.dart';
+import 'package:path/path.dart' as p;
 
 import '../barback/build_environment.dart';
 import '../barback/pub_package_provider.dart';
@@ -20,8 +22,12 @@ final _arrow = getSpecial('\u2192', '=>');
 
 /// Handles the `serve` pub command.
 class ServeCommand extends PubCommand {
-  String get description => "Run a local web development server.";
-  String get usage => "pub serve";
+  String get description =>
+      'Run a local web development server.\n\n'
+      'By default, this serves "web/" and "test/", but an explicit list of \n'
+      'directories to serve can be provided as well.';
+  String get usage => "pub serve [directories...]";
+  final takesArguments = true;
 
   PubPackageProvider _provider;
 
@@ -35,7 +41,7 @@ class ServeCommand extends PubCommand {
 
   ServeCommand() {
     commandParser.addOption('port', defaultsTo: '8080',
-        help: 'The port to listen on.');
+        help: 'The base port to listen on.');
 
     // A hidden option for the tests to work around a bug in some of the OS X
     // bots where "localhost" very rarely resolves to the IPv4 loopback address
@@ -66,24 +72,26 @@ class ServeCommand extends PubCommand {
         WatcherType.POLLING : WatcherType.AUTO;
 
     return BuildEnvironment.create(entrypoint, hostname, port, mode,
-        watcherType, ["web"].toSet(),
+        watcherType, _directoriesToServe,
         useDart2JS: useDart2JS).then((environment) {
 
       // In release mode, strip out .dart files since all relevant ones have
       // been compiled to JavaScript already.
       if (mode == BarbackMode.RELEASE) {
-        environment.server.allowAsset = (url) => !url.path.endsWith(".dart");
+        for (var server in environment.servers) {
+          server.allowAsset = (url) => !url.path.endsWith(".dart");
+        }
       }
 
       /// This completer is used to keep pub running (by not completing) and
       /// to pipe fatal errors to pub's top-level error-handling machinery.
       var completer = new Completer();
 
-      environment.server.barback.errors.listen((error) {
+      environment.barback.errors.listen((error) {
         log.error(log.red("Build error:\n$error"));
       });
 
-      environment.server.barback.results.listen((result) {
+      environment.barback.results.listen((result) {
         if (result.succeeded) {
           // TODO(rnystrom): Report using growl/inotify-send where available.
           log.message("Build completed ${log.green('successfully')}");
@@ -95,28 +103,83 @@ class ServeCommand extends PubCommand {
         if (!completer.isCompleted) completer.completeError(error, stackTrace);
       });
 
-      environment.server.results.listen((result) {
-        if (result.isSuccess) {
-          log.message("${log.green('GET')} ${result.url.path} $_arrow "
-              "${result.id}");
-          return;
-        }
-
-        var msg = "${log.red('GET')} ${result.url.path} $_arrow";
-        var error = result.error.toString();
-        if (error.contains("\n")) {
-          log.message("$msg\n${prefixLines(error)}");
-        } else {
-          log.message("$msg $error");
-        }
-      }, onError: (error, [stackTrace]) {
-        if (!completer.isCompleted) completer.completeError(error, stackTrace);
-      });
-
-      log.message("Serving ${entrypoint.root.name} "
-          "on http://$hostname:${environment.server.port}");
+      var directoryLength = environment.servers
+          .map((server) => server.rootDirectory.length)
+          .reduce(math.max);
+      for (var server in environment.servers) {
+        // Add two characters to account for "[" and "]".
+        var directoryPrefix = log.gray(
+            padRight("[${server.rootDirectory}]", directoryLength + 2));
+        server.results.listen((result) {
+          if (result.isSuccess) {
+            log.message("$directoryPrefix ${log.green('GET')} "
+                "${result.url.path} $_arrow ${result.id}");
+            return;
+          }
+
+          var msg = "$directoryPrefix ${log.red('GET')} ${result.url.path} "
+              "$_arrow";
+          var error = result.error.toString();
+          if (error.contains("\n")) {
+            log.message("$msg\n${prefixLines(error)}");
+          } else {
+            log.message("$msg $error");
+          }
+        }, onError: (error, [stackTrace]) {
+          if (completer.isCompleted) return;
+          completer.completeError(error, stackTrace);
+        });
+
+        log.message("Serving ${entrypoint.root.name} "
+            "${padRight(server.rootDirectory, directoryLength)} "
+            "on ${log.bold('http://$hostname:${server.port}')}");
+      }
 
       return completer.future;
     });
   }
+
+  /// Returns the set of directories that will be served from servers exposed to
+  /// the user.
+  ///
+  /// Throws a [UsageException] if the command-line arguments are invalid.
+  List<String> get _directoriesToServe {
+    if (commandOptions.rest.isEmpty) {
+      var directories = ['web', 'test'].where(dirExists).toList();
+      if (directories.isNotEmpty) return directories;
+      usageError(
+          'Your package must have "web" and/or "test" directories to serve,\n'
+          'or you must pass in directories to serve explicitly.');
+    }
+
+    var directories = commandOptions.rest.map(p.normalize).toList();
+    var invalid = directories.where((dir) => !isBeneath(dir, '.'));
+    if (invalid.isNotEmpty) {
+      usageError("${_directorySentence(invalid, "isn't", "aren't")} in this "
+          "package.");
+    }
+
+    var nonExistent = directories.where((dir) => !dirExists(dir));
+    if (nonExistent.isNotEmpty) {
+      usageError("${_directorySentence(nonExistent, "doesn't", "don't")} "
+          "exist.");
+    }
+
+    return directories;
+  }
+
+  /// Converts a list of [directoryNames] to a sentence.
+  ///
+  /// After the list of directories, [singularVerb] will be used if there is
+  /// only one directory and [pluralVerb] will be used if there are more than
+  /// one.
+  String _directorySentence(Iterable<String> directoryNames,
+      String singularVerb, String pluralVerb) {
+    var directories = pluralize('Directory', directoryNames.length,
+        plural: 'Directories');
+    var names = toSentence(ordered(directoryNames).map((dir) => '"$dir"'));
+    var verb = pluralize(singularVerb, directoryNames.length,
+        plural: pluralVerb);
+    return "$directories $names $verb";
+  }
 }
diff --git a/lib/src/log.dart b/lib/src/log.dart
index 0dcf088d339cfbb43c1ce0d2275cab8b57d9d81c..fd1166d99644e29795f0301e41fbcf2c3e532e8b 100644
--- a/lib/src/log.dart
+++ b/lib/src/log.dart
@@ -41,6 +41,7 @@ final _green = getSpecial('\u001b[32m');
 final _magenta = getSpecial('\u001b[35m');
 final _red = getSpecial('\u001b[31m');
 final _yellow = getSpecial('\u001b[33m');
+final _gray = getSpecial('\u001b[1;30m');
 final _none = getSpecial('\u001b[0m');
 final _bold = getSpecial('\u001b[1m');
 
@@ -237,6 +238,12 @@ Future progress(String message, Future callback()) {
 /// Use this to highlight the most important piece of a long chunk of text.
 String bold(text) => "$_bold$text$_none";
 
+/// Wraps [text] in the ANSI escape codes to make it gray when on a platform
+/// that supports that.
+///
+/// Use this for text that's less important than the text around it.
+String gray(text) => "$_gray$text$_none";
+
 /// Wraps [text] in the ANSI escape codes to color it cyan when on a platform
 /// that supports that.
 ///
diff --git a/test/pub_test.dart b/test/pub_test.dart
index cc41e47456b94e2d2e5d5f35d501674eaa398964..3fc4c1370e5273f01b7f03d25bf4174c408f91a7 100644
--- a/test/pub_test.dart
+++ b/test/pub_test.dart
@@ -209,6 +209,28 @@ main() {
             ''');
     });
 
+    integration('shows non-truncated help', () {
+      schedulePub(args: ['help', 'serve'],
+          output: '''
+            Run a local web development server.
+
+            By default, this serves "web/" and "test/", but an explicit list of 
+            directories to serve can be provided as well.
+
+            Usage: pub serve [directories...]
+            -h, --help               Print usage information for this command.
+                --port               The base port to listen on.
+                                     (defaults to "8080")
+
+                --[no-]dart2js       Compile Dart to JavaScript.
+                                     (defaults to on)
+
+                --[no-]force-poll    Force the use of a polling filesystem watcher.
+                --mode               Mode to run transformers in.
+                                     (defaults to "debug")
+            ''');
+    });
+
     integration('shows help for a subcommand', () {
       schedulePub(args: ['help', 'cache', 'list'],
           output: '''
diff --git a/test/serve/roots/serves_urls_from_custom_roots_test.dart b/test/serve/roots/serves_urls_from_custom_roots_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..221b53aab078330b1e24117a893ffb0630b36349
--- /dev/null
+++ b/test/serve/roots/serves_urls_from_custom_roots_test.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS d.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 pub_tests;
+
+import 'package:path/path.dart' as p;
+import 'package:scheduled_test/scheduled_test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+import '../utils.dart';
+
+main() {
+  initConfig();
+  integration("serves URLs from custom roots", () {
+    d.dir(appPath, [
+      d.appPubspec(),
+      d.dir("example", [
+        d.dir("foo", [d.file("bar", "contents")])
+      ]),
+      d.dir("dir", [d.file("baz", "contents")]),
+      d.dir("web", [d.file("bang", "contents")])
+    ]).create();
+
+    pubServe(args: [p.join("example", "foo"), "dir"]);
+    requestShouldSucceed("bar", "contents", root: p.join("example", "foo"));
+    requestShouldSucceed("baz", "contents", root: "dir");
+    requestShould404("bang", root: "dir");
+    endPubServe();
+  });
+}
diff --git a/test/serve/roots/serves_web_and_test_dirs_by_default_test.dart b/test/serve/roots/serves_web_and_test_dirs_by_default_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..c53e8ba621bd47e8736aff49b0b91119ad063a36
--- /dev/null
+++ b/test/serve/roots/serves_web_and_test_dirs_by_default_test.dart
@@ -0,0 +1,27 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS d.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 pub_tests;
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+import '../utils.dart';
+
+main() {
+  initConfig();
+  integration("serves web/ and test/ dirs by default", () {
+    d.dir(appPath, [
+      d.appPubspec(),
+      d.dir("web", [d.file("foo", "contents")]),
+      d.dir("test", [d.file("bar", "contents")]),
+      d.dir("example", [d.file("baz", "contents")])
+    ]).create();
+
+    pubServe();
+    requestShouldSucceed("foo", "contents", root: "web");
+    requestShouldSucceed("bar", "contents", root: "test");
+    requestShould404("baz", root: "web");
+    endPubServe();
+  });
+}
diff --git a/test/serve/roots/throws_an_error_by_default_if_web_and_test_dont_exist_test.dart b/test/serve/roots/throws_an_error_by_default_if_web_and_test_dont_exist_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..11de9cdc3a87f70d42bc8c6cf32f7938eef50df1
--- /dev/null
+++ b/test/serve/roots/throws_an_error_by_default_if_web_and_test_dont_exist_test.dart
@@ -0,0 +1,29 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS d.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 pub_tests;
+
+import 'package:scheduled_test/scheduled_stream.dart';
+import 'package:scheduled_test/scheduled_test.dart';
+
+import '../../../lib/src/exit_codes.dart' as exit_codes;
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+import '../utils.dart';
+
+main() {
+  initConfig();
+  integration("throws an error by default if web and test don't exist", () {
+    d.dir(appPath, [
+      d.appPubspec()
+    ]).create();
+
+    var server = startPubServe(createWebDir: false);
+    server.stderr.expect(emitsLines(
+        'Your package must have "web" and/or "test" directories to serve,\n'
+        'or you must pass in directories to serve explicitly.'));
+    server.shouldExit(exit_codes.USAGE);
+  });
+}
diff --git a/test/serve/roots/throws_an_error_if_custom_roots_are_outside_package_test.dart b/test/serve/roots/throws_an_error_if_custom_roots_are_outside_package_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..37dd2696a530e17d719a37121d80378d97ce33a3
--- /dev/null
+++ b/test/serve/roots/throws_an_error_if_custom_roots_are_outside_package_test.dart
@@ -0,0 +1,26 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS d.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 pub_tests;
+
+import 'package:scheduled_test/scheduled_test.dart';
+
+import '../../../lib/src/exit_codes.dart' as exit_codes;
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+import '../utils.dart';
+
+main() {
+  initConfig();
+  integration("throws an error if custom roots are outside the package", () {
+    d.dir(appPath, [
+      d.appPubspec()
+    ]).create();
+
+    var server = startPubServe(args: [".."]);
+    server.stderr.expect('Directory ".." isn\'t in this package.');
+    server.shouldExit(exit_codes.USAGE);
+  });
+}
diff --git a/test/serve/roots/throws_an_error_if_custom_roots_dont_exist_test.dart b/test/serve/roots/throws_an_error_if_custom_roots_dont_exist_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..c2275bbc16d16fe6c8788301726fb701447806e9
--- /dev/null
+++ b/test/serve/roots/throws_an_error_if_custom_roots_dont_exist_test.dart
@@ -0,0 +1,27 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS d.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 pub_tests;
+
+import 'package:scheduled_test/scheduled_test.dart';
+
+import '../../../lib/src/exit_codes.dart' as exit_codes;
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+import '../utils.dart';
+
+main() {
+  initConfig();
+  integration("throws an error if custom roots don't exist", () {
+    d.dir(appPath, [
+      d.appPubspec(),
+      d.dir("baz")
+    ]).create();
+
+    var server = startPubServe(args: ["foo", "bar", "baz"]);
+    server.stderr.expect('Directories "bar" and "foo" don\'t exist.');
+    server.shouldExit(exit_codes.USAGE);
+  });
+}
diff --git a/test/serve/utils.dart b/test/serve/utils.dart
index 441e139eec65c929112c4ab1f72496dd7dd17d25..0f04f3fc86bd34bdc422ec0149f030161589d1e8 100644
--- a/test/serve/utils.dart
+++ b/test/serve/utils.dart
@@ -14,13 +14,15 @@ import 'package:scheduled_test/scheduled_process.dart';
 import 'package:scheduled_test/scheduled_stream.dart';
 import 'package:scheduled_test/scheduled_test.dart';
 
+import '../descriptor.dart' as d;
 import '../test_pub.dart';
 
 /// The pub process running "pub serve".
 ScheduledProcess _pubServer;
 
-/// The ephemeral port assigned to the running server.
-int _port;
+/// The ephemeral ports assigned to the running servers, associated with the
+/// directories they're serving.
+final _ports = new Map<String, int>();
 
 /// The web socket connection to the running pub process, or `null` if no
 /// connection has been made.
@@ -100,7 +102,8 @@ class DartTransformer extends Transformer {
 /// so may be used to test for errors in the initialization process.
 ///
 /// Returns the `pub serve` process.
-ScheduledProcess startPubServe([Iterable<String> args]) {
+ScheduledProcess startPubServe({Iterable<String> args,
+    bool createWebDir: true}) {
   // Use port 0 to get an ephemeral port.
   var pubArgs = ["serve", "--port=0", "--hostname=127.0.0.1", "--force-poll"];
 
@@ -110,6 +113,7 @@ ScheduledProcess startPubServe([Iterable<String> args]) {
   // timeout to cope with that.
   currentSchedule.timeout *= 1.5;
 
+  if (createWebDir) d.dir(appPath, [d.dir("web")]).create();
   return startPub(args: pubArgs);
 }
 
@@ -118,11 +122,17 @@ ScheduledProcess startPubServe([Iterable<String> args]) {
 ///
 /// If [shouldGetFirst] is `true`, validates that pub get is run first.
 ///
+/// If [createWebDir] is `true`, creates a `web/` directory if one doesn't exist
+/// so pub doesn't complain about having nothing to serve.
+///
 /// Returns the `pub serve` process.
-ScheduledProcess pubServe({bool shouldGetFirst: false, Iterable<String> args}) {
-  _pubServer = startPubServe(args);
+ScheduledProcess pubServe({bool shouldGetFirst: false, bool createWebDir: true,
+    Iterable<String> args}) {
+  _pubServer = startPubServe(args: args, createWebDir: createWebDir);
 
   currentSchedule.onComplete.schedule(() {
+    _ports.clear();
+
     if (_webSocket != null) {
       _webSocket.close();
       _webSocket = null;
@@ -134,16 +144,25 @@ ScheduledProcess pubServe({bool shouldGetFirst: false, Iterable<String> args}) {
     _pubServer.stdout.expect(consumeThrough("Got dependencies!"));
   }
 
-  expect(schedule(() => _pubServer.stdout.next()).then(_parsePort), completes);
+  // The server should emit one or more ports.
+  _pubServer.stdout.expect(
+      consumeWhile(predicate(_parsePort, 'emits server url')));
+  schedule(() => expect(_ports, isNot(isEmpty)));
+
   return _pubServer;
 }
 
+/// The regular expression for parsing pub's output line describing the URL for
+/// the server.
+final _parsePortRegExp = new RegExp(r"([^ ]+) +on http://127\.0\.0\.1:(\d+)");
+
 /// Parses the port number from the "Serving blah on 127.0.0.1:1234" line
 /// printed by pub serve.
-void _parsePort(String line) {
-  var match = new RegExp(r"127\.0\.0\.1:(\d+)").firstMatch(line);
-  assert(match != null);
-  _port = int.parse(match[1]);
+bool _parsePort(String line) {
+  var match = _parsePortRegExp.firstMatch(line);
+  if (match == null) return false;
+  _ports[match[1]] = int.parse(match[2]);
+  return true;
 }
 
 void endPubServe() {
@@ -154,10 +173,11 @@ void endPubServe() {
 /// verifies that it responds with a body that matches [expectation].
 ///
 /// [expectation] may either be a [Matcher] or a string to match an exact body.
+/// [root] indicates which server should be accessed, and defaults to "web".
 /// [headers] may be either a [Matcher] or a map to match an exact headers map.
-void requestShouldSucceed(String urlPath, expectation, {headers}) {
+void requestShouldSucceed(String urlPath, expectation, {String root, headers}) {
   schedule(() {
-    return http.get("http://127.0.0.1:$_port/$urlPath").then((response) {
+    return http.get("${_serverUrl(root)}/$urlPath").then((response) {
       if (expectation != null) expect(response.body, expectation);
       if (headers != null) expect(response.headers, headers);
     });
@@ -166,9 +186,11 @@ void requestShouldSucceed(String urlPath, expectation, {headers}) {
 
 /// Schedules an HTTP request to the running pub server with [urlPath] and
 /// verifies that it responds with a 404.
-void requestShould404(String urlPath) {
+///
+/// [root] indicates which server should be accessed, and defaults to "web".
+void requestShould404(String urlPath, {String root}) {
   schedule(() {
-    return http.get("http://127.0.0.1:$_port/$urlPath").then((response) {
+    return http.get("${_serverUrl(root)}/$urlPath").then((response) {
       expect(response.statusCode, equals(404));
     });
   }, "request $urlPath");
@@ -178,11 +200,12 @@ void requestShould404(String urlPath) {
 /// verifies that it responds with a redirect to the given [redirectTarget].
 ///
 /// [redirectTarget] may be either a [Matcher] or a string to match an exact
-/// URL.
-void requestShouldRedirect(String urlPath, redirectTarget) {
+/// URL. [root] indicates which server should be accessed, and defaults to
+/// "web".
+void requestShouldRedirect(String urlPath, redirectTarget, {String root}) {
   schedule(() {
     var request = new http.Request("GET",
-        Uri.parse("http://127.0.0.1:$_port/$urlPath"));
+        Uri.parse("${_serverUrl(root)}/$urlPath"));
     request.followRedirects = false;
     return request.send().then((response) {
       expect(response.statusCode ~/ 100, equals(3));
@@ -194,9 +217,11 @@ void requestShouldRedirect(String urlPath, redirectTarget) {
 
 /// Schedules an HTTP POST to the running pub server with [urlPath] and verifies
 /// that it responds with a 405.
-void postShould405(String urlPath) {
+///
+/// [root] indicates which server should be accessed, and defaults to "web".
+void postShould405(String urlPath, {String root}) {
   schedule(() {
-    return http.post("http://127.0.0.1:$_port/$urlPath").then((response) {
+    return http.post("${_serverUrl(root)}/$urlPath").then((response) {
       expect(response.statusCode, equals(405));
     });
   }, "request $urlPath");
@@ -217,10 +242,13 @@ Future _ensureWebSocket() {
   if (_webSocket != null) return new Future.value();
 
   // Server should already be running.
-  assert(_pubServer != null);
-  assert(_port != null);
+  expect(_pubServer, isNotNull);
+  expect(_ports, isNot(isEmpty));
 
-  return WebSocket.connect("ws://127.0.0.1:$_port").then((socket) {
+  // TODO(nweiz): once we have a separate port for a web interface into the
+  // server, use that port for the websocket interface.
+  var port = _ports.values.first;
+  return WebSocket.connect("ws://127.0.0.1:$port").then((socket) {
     _webSocket = socket;
     // TODO(rnystrom): Works around #13913.
     _webSocketBroadcastStream = _webSocket.asBroadcastStream();
@@ -241,4 +269,11 @@ void webSocketShouldReply(request, expectation, {bool encodeRequest: true}) {
       expect(JSON.decode(value), expectation);
     });
   }), "send $request to web socket and expect reply that $expectation");
+}
+
+/// Returns the URL for the server serving from [root].
+String _serverUrl([String root]) {
+  if (root == null) root = 'web';
+  expect(_ports, contains(root));
+  return "http://127.0.0.1:${_ports[root]}";
 }
\ No newline at end of file