Add support for configuration presets.

Closes #83

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1782473005 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4f6e12..36b1fe5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 ## 0.12.12
 
+* Add support for [test presets][]. These are defined using the `presets` field
+  in the package configuration file. They can be selected by passing `--preset`
+  or `-P`, or by using the `add_presets` field in the package configuration
+  file.
+
 * Add an `on_os` field to the package configuration file that allows users to
   select different configuration for different operating systems.
 
@@ -10,6 +15,8 @@
   the `test` executable itself is running on iOS, not when it's running browser
   tests on an iOS browser.
 
+[test presets]: https://github.com/dart-lang/test/blob/master/doc/package_config.md#configuration-presets
+
 ## 0.12.11+2
 
 * Update to `shelf_web_socket` 0.2.0.
diff --git a/doc/package_config.md b/doc/package_config.md
index adf4057..07c4eca 100644
--- a/doc/package_config.md
+++ b/doc/package_config.md
@@ -43,6 +43,9 @@
 * [Configuring Platforms](#configuring-platforms)
   * [`on_os`](#on_os)
   * [`on_platform`](#on_platform)
+* [Configuration Presets](#configuration-presets)
+  * [`presets`](#presets)
+  * [`add_preset`](#add_preset)
 
 ## Test Configuration
 
@@ -288,8 +291,8 @@
 This field adds additional tags. It's technically
 [test configuration](#test-configuration), but it's usually used in more
 specific contexts. For example, when included in a tag's configuration, it can
-be used to enable tag inheritance, where adding one tag implicitly adds other as
-well. It takes a list of tag name strings.
+be used to enable tag inheritance, where adding one tag implicitly adds another
+as well. It takes a list of tag name strings.
 
 ```yaml
 tags:
@@ -364,3 +367,71 @@
 when running on a particular operating system, use [`on_os`](#on_os) instead.
 
 This field counts as [test configuration](#test-configuration).
+
+## Configuration Presets
+
+*Presets* are collections of configuration that can be explicitly selected on
+the command-line. They're useful for quickly selecting options that are
+frequently used together, for providing special configuration for continuous
+integration systems, and for defining more complex logic than can be expressed
+directly using command-line arguments.
+
+Presets can be selected on the command line using the `--preset` or `-P` flag.
+Any number of presets can be selected this way; if they conflict, the last one
+selected wins. Only presets that are defined in the configuration file may be
+selected.
+
+### `presets`
+
+This field defines which presets are available to select. It takes a map from
+preset names to configuration maps that are applied when those presets are
+selected. These configuration maps are just like the top level of the
+configuration file, and allow any fields that may be used in the context where
+`presets` was used.
+
+```yaml
+presets:
+  # Use this when you need completely un-munged stack traces.
+  debug:
+    verbose_trace: false
+    js_trace: true
+
+  # Shortcut for running only browser tests.
+  browser:
+    paths:
+    - test/runner/browser
+    - test/runner/pub_serve_test.dart
+```
+
+The `presets` field counts as [test configuration](#test-configuration). It can
+be useful to use it in combination with other fields for advanced preset
+behavior.
+
+```yaml
+tags:
+  chrome:
+    skip: "Our Chrome launcher is busted. See issue 1234."
+
+    # Pass -P force to verify that the launcher is still busted.
+    presets: {force: {skip: false}}
+```
+
+### `add_presets`
+
+This field selects additional presets. It's technically
+[runner configuration](#runner-configuration), but it's usually used in more
+specific contexts. For example, when included in a preset's configuration, it
+can be used to enable preset inheritance, where selecting one preset implicitly
+selects another as well. It takes a list of preset name strings.
+
+```yaml
+presets:
+  # Shortcut for running only browser tests.
+  browser:
+    paths: [test/runner/browser]
+
+  # Shortcut for running only Chrome tests.
+  chrome:
+    filename: "chrome_*_test.dart"
+    add_presets: [browser]
+```
diff --git a/lib/src/backend/metadata.dart b/lib/src/backend/metadata.dart
index 6ff5412..91bb54d 100644
--- a/lib/src/backend/metadata.dart
+++ b/lib/src/backend/metadata.dart
@@ -140,6 +140,7 @@
     // If there's no tag-specific metadata, or if none of it applies, just
     // return the metadata as-is.
     if (forTag == null || tags == null) return _unresolved();
+    tags = new Set.from(tags);
     forTag = new Map.from(forTag);
 
     // Otherwise, resolve the tag-specific components. Doing this eagerly means
diff --git a/lib/src/executable.dart b/lib/src/executable.dart
index 8dd546e..8c82c47 100644
--- a/lib/src/executable.dart
+++ b/lib/src/executable.dart
@@ -104,6 +104,17 @@
     return;
   }
 
+  var undefinedPresets =
+      configuration.chosenPresets
+          .where((preset) => !configuration.knownPresets.contains(preset))
+          .toList();
+  if (undefinedPresets.isNotEmpty) {
+    _printUsage("Undefined ${pluralize('preset', undefinedPresets.length)} "
+        "${toSentence(undefinedPresets.map((preset) => '"$preset"'))}.");
+    exitCode = exit_codes.usage;
+    return;
+  }
+
   if (configuration.pubServeUrl != null && !_usesTransformer) {
     stderr.write('''
 When using --pub-serve, you must include the "test/pub_serve" transformer in
@@ -169,7 +180,7 @@
     output = stderr;
   }
 
-  output.write("""$message
+  output.write("""${wordWrap(message)}
 
 Usage: pub run test:test [files or directories...]
 
diff --git a/lib/src/runner/configuration.dart b/lib/src/runner/configuration.dart
index aaeb94f..b8e2ed5 100644
--- a/lib/src/runner/configuration.dart
+++ b/lib/src/runner/configuration.dart
@@ -21,6 +21,12 @@
 
 /// A class that encapsulates the command-line configuration of the test runner.
 class Configuration {
+  /// An empty configuration with only default values.
+  ///
+  /// Using this is slightly more efficient than manually constructing a new
+  /// configuration with no arguments.
+  static final empty = new Configuration._();
+
   /// The usage string for the command-line arguments.
   static String get usage => args.usage;
 
@@ -107,6 +113,14 @@
   List<TestPlatform> get platforms => _platforms ?? [TestPlatform.vm];
   final List<TestPlatform> _platforms;
 
+  /// The set of presets to use.
+  ///
+  /// Any chosen presets for the parent configuration are added to the chosen
+  /// preset sets for child configurations as well.
+  ///
+  /// Note that the order of this set matters.
+  final Set<String> chosenPresets;
+
   /// Only run tests whose tags match this selector.
   ///
   /// When [merge]d, this is intersected with the other configuration's included
@@ -142,17 +156,21 @@
       forTag: mapMap(tags, value: (_, config) => config.metadata),
       onPlatform: mapMap(onPlatform, value: (_, config) => config.metadata));
 
-  /// The set of tags that have been declaredin any way in this configuration.
+  /// The set of tags that have been declared in any way in this configuration.
   Set<String> get knownTags {
     if (_knownTags != null) return _knownTags;
 
     var known = includeTags.variables.toSet()
       ..addAll(excludeTags.variables)
       ..addAll(addTags);
-    tags.forEach((selector, config) {
+
+    for (var selector in tags.keys) {
       known.addAll(selector.variables);
-      known.addAll(config.knownTags);
-    });
+    }
+
+    for (var configuration in _children) {
+      known.addAll(configuration.knownTags);
+    }
 
     _knownTags = new UnmodifiableSetView(known);
     return _knownTags;
@@ -166,6 +184,40 @@
   /// configuration fields, but that isn't enforced.
   final Map<PlatformSelector, Configuration> onPlatform;
 
+  /// Configuration presets.
+  ///
+  /// These are configurations that can be explicitly selected by the user via
+  /// the command line. Preset configuration takes precedence over the base
+  /// configuration.
+  ///
+  /// This is guaranteed not to have any keys that match [chosenPresets]; those
+  /// are resolved when the configuration is constructed.
+  final Map<String, Configuration> presets;
+
+  /// All preset names that are known to be valid.
+  ///
+  /// This includes presets that have already been resolved.
+  Set<String> get knownPresets {
+    if (_knownPresets != null) return _knownPresets;
+
+    var known = presets.keys.toSet();
+    for (var configuration in _children) {
+      known.addAll(configuration.knownPresets);
+    }
+
+    _knownPresets = new UnmodifiableSetView(known);
+    return _knownPresets;
+  }
+  Set<String> _knownPresets;
+
+  /// All child configurations of [this] that may be selected under various
+  /// circumstances.
+  Iterable<Configuration> get _children sync* {
+    yield* tags.values;
+    yield* onPlatform.values;
+    yield* presets.values;
+  }
+
   /// Parses the configuration from [args].
   ///
   /// Throws a [FormatException] if [args] are invalid.
@@ -177,7 +229,97 @@
   /// a [FormatException] if its contents are invalid.
   factory Configuration.load(String path) => load(path);
 
-  Configuration({
+  factory Configuration({
+      bool help,
+      bool version,
+      bool verboseTrace,
+      bool jsTrace,
+      bool skip,
+      String skipReason,
+      PlatformSelector testOn,
+      bool pauseAfterLoad,
+      bool color,
+      String packageRoot,
+      String reporter,
+      int pubServePort,
+      int concurrency,
+      Timeout timeout,
+      Pattern pattern,
+      Iterable<TestPlatform> platforms,
+      Iterable<String> paths,
+      Glob filename,
+      Iterable<String> chosenPresets,
+      BooleanSelector includeTags,
+      BooleanSelector excludeTags,
+      Iterable addTags,
+      Map<BooleanSelector, Configuration> tags,
+      Map<PlatformSelector, Configuration> onPlatform,
+      Map<String, Configuration> presets}) {
+    _unresolved() => new Configuration._(
+        help: help,
+        version: version,
+        verboseTrace: verboseTrace,
+        jsTrace: jsTrace,
+        skip: skip,
+        skipReason: skipReason,
+        testOn: testOn,
+        pauseAfterLoad: pauseAfterLoad,
+        color: color,
+        packageRoot: packageRoot,
+        reporter: reporter,
+        pubServePort: pubServePort,
+        concurrency: concurrency,
+        timeout: timeout,
+        pattern: pattern,
+        platforms: platforms,
+        paths: paths,
+        filename: filename,
+        chosenPresets: chosenPresets,
+        includeTags: includeTags,
+        excludeTags: excludeTags,
+        addTags: addTags,
+
+        // Make sure we pass [chosenPresets] to the child configurations as
+        // well. This ensures that 
+        tags: _withChosenPresets(tags, chosenPresets),
+        onPlatform: _withChosenPresets(onPlatform, chosenPresets),
+        presets: _withChosenPresets(presets, chosenPresets));
+
+    if (chosenPresets == null) return _unresolved();
+    chosenPresets = new Set.from(chosenPresets);
+
+    if (presets == null) return _unresolved();
+    presets = new Map.from(presets);
+
+    var knownPresets = presets.keys.toSet();
+
+    var merged = chosenPresets.fold(Configuration.empty, (merged, preset) {
+      if (!presets.containsKey(preset)) return merged;
+      return merged.merge(presets.remove(preset));
+    });
+
+    var result = merged == Configuration.empty
+        ? _unresolved()
+        : _unresolved().merge(merged);
+
+    // Make sure the configuration knows about presets that were selected and
+    // thus removed from [presets].
+    result._knownPresets = result.knownPresets.union(knownPresets);
+
+    return result;
+  }
+
+  static Map<Object, Configuration> _withChosenPresets(
+      Map<Object, Configuration> map, Set<String> chosenPresets) {
+    if (map == null || chosenPresets == null) return map;
+    return mapMap(map, value: (_, config) => config.change(
+        chosenPresets: config.chosenPresets.union(chosenPresets)));
+  }
+
+  /// Creates new Configuration.
+  ///
+  /// Unlike [new Configuration], this assumes [presets] is already resolved.
+  Configuration._({
           bool help,
           bool version,
           bool verboseTrace,
@@ -196,11 +338,13 @@
           Iterable<TestPlatform> platforms,
           Iterable<String> paths,
           Glob filename,
+          Iterable<String> chosenPresets,
           BooleanSelector includeTags,
           BooleanSelector excludeTags,
           Iterable addTags,
           Map<BooleanSelector, Configuration> tags,
-          Map<PlatformSelector, Configuration> onPlatform})
+          Map<PlatformSelector, Configuration> onPlatform,
+          Map<String, Configuration> presets})
       : _help = help,
         _version = version,
         _verboseTrace = verboseTrace,
@@ -221,13 +365,13 @@
         _platforms = _list(platforms),
         _paths = _list(paths),
         _filename = filename,
+        chosenPresets = new Set.from(chosenPresets ?? []),
         includeTags = includeTags ?? BooleanSelector.all,
         excludeTags = excludeTags ?? BooleanSelector.none,
-        addTags = addTags?.toSet() ?? new Set(),
-        tags = tags == null ? const {} : new Map.unmodifiable(tags),
-        onPlatform = onPlatform == null
-            ? const {}
-            : new Map.unmodifiable(onPlatform) {
+        addTags = new UnmodifiableSetView(addTags?.toSet() ?? new Set()),
+        tags = _map(tags),
+        onPlatform = _map(onPlatform),
+        presets = _map(presets) {
     if (_filename != null && _filename.context.style != p.style) {
       throw new ArgumentError(
           "filename's context must match the current operating system, was "
@@ -235,24 +379,33 @@
     }
   }
 
-  /// Returns a [input] as a list or `null`.
+  /// Returns a [input] as an unmodifiable list or `null`.
   ///
   /// If [input] is `null` or empty, this returns `null`. Otherwise, it returns
   /// `input.toList()`.
   static List _list(Iterable input) {
     if (input == null) return null;
-    input = input.toList();
+    input = new List.unmodifiable(input);
     if (input.isEmpty) return null;
     return input;
   }
 
+  /// Returns an modifiable copy of [input] or an empty unmodifiable map.
+  static Map _map(Map input) {
+    if (input == null) return const {};
+    return new Map.unmodifiable(input);
+  }
+
   /// Merges this with [other].
   ///
   /// For most fields, if both configurations have values set, [other]'s value
   /// takes precedence. However, certain fields are merged together instead.
   /// This is indicated in those fields' documentation.
   Configuration merge(Configuration other) {
-    return new Configuration(
+    if (this == Configuration.empty) return other;
+    if (other == Configuration.empty) return this;
+
+    var result = new Configuration(
         help: other._help ?? _help,
         version: other._version ?? _version,
         verboseTrace: other._verboseTrace ?? _verboseTrace,
@@ -271,12 +424,84 @@
         platforms: other._platforms ?? _platforms,
         paths: other._paths ?? _paths,
         filename: other._filename ?? _filename,
+        chosenPresets: chosenPresets.union(other.chosenPresets),
         includeTags: includeTags.intersection(other.includeTags),
         excludeTags: excludeTags.union(other.excludeTags),
         addTags: other.addTags.union(addTags),
-        tags: mergeMaps(tags, other.tags,
-            value: (config1, config2) => config1.merge(config2)),
-        onPlatform: mergeMaps(onPlatform, other.onPlatform,
-            value: (config1, config2) => config1.merge(config2)));
+        tags: _mergeConfigMaps(tags, other.tags),
+        onPlatform: _mergeConfigMaps(onPlatform, other.onPlatform),
+        presets: _mergeConfigMaps(presets, other.presets));
+
+    // Make sure the merged config preserves any presets that were chosen and
+    // discarded.
+    result._knownPresets = knownPresets.union(other.knownPresets);
+    return result;
   }
+
+  /// Returns a copy of this configuration with the given fields updated.
+  ///
+  /// Note that unlike [merge], this has no merging behavior—the old value is
+  /// always replaced by the new one.
+  Configuration change({
+      bool help,
+      bool version,
+      bool verboseTrace,
+      bool jsTrace,
+      bool skip,
+      String skipReason,
+      PlatformSelector testOn,
+      bool pauseAfterLoad,
+      bool color,
+      String packageRoot,
+      String reporter,
+      int pubServePort,
+      int concurrency,
+      Timeout timeout,
+      Pattern pattern,
+      Iterable<TestPlatform> platforms,
+      Iterable<String> paths,
+      Glob filename,
+      Iterable<String> chosenPresets,
+      BooleanSelector includeTags,
+      BooleanSelector excludeTags,
+      Iterable addTags,
+      Map<BooleanSelector, Configuration> tags,
+      Map<PlatformSelector, Configuration> onPlatform,
+      Map<String, Configuration> presets}) {
+    return new Configuration(
+        help: help ?? _help,
+        version: version ?? _version,
+        verboseTrace: verboseTrace ?? _verboseTrace,
+        jsTrace: jsTrace ?? _jsTrace,
+        skip: skip ?? _skip,
+        skipReason: skipReason ?? this.skipReason,
+        testOn: testOn ?? this.testOn,
+        pauseAfterLoad: pauseAfterLoad ?? _pauseAfterLoad,
+        color: color ?? _color,
+        packageRoot: packageRoot ?? _packageRoot,
+        reporter: reporter ?? _reporter,
+        pubServePort: pubServePort ?? pubServeUrl?.port,
+        concurrency: concurrency ?? _concurrency,
+        timeout: timeout ?? this.timeout,
+        pattern: pattern ?? this.pattern,
+        platforms: platforms ?? _platforms,
+        paths: paths ?? _paths,
+        filename: filename ?? _filename,
+        chosenPresets: chosenPresets ?? this.chosenPresets,
+        includeTags: includeTags ?? this.includeTags,
+        excludeTags: excludeTags ?? this.excludeTags,
+        addTags: addTags ?? this.addTags,
+        tags: tags ?? this.tags,
+        onPlatform: onPlatform ?? this.onPlatform,
+        presets: presets ?? this.presets);
+  }
+
+  /// Merges two maps whose values are [Configuration]s.
+  ///
+  /// Any overlapping keys in the maps have their configurations merged in the
+  /// returned map.
+  Map<Object, Configuration> _mergeConfigMaps(Map<Object, Configuration> map1,
+          Map<Object, Configuration> map2) =>
+      mergeMaps(map1, map2,
+          value: (config1, config2) => config1.merge(config2));
 }
diff --git a/lib/src/runner/configuration/args.dart b/lib/src/runner/configuration/args.dart
index 8ce1d1f..5b96912 100644
--- a/lib/src/runner/configuration/args.dart
+++ b/lib/src/runner/configuration/args.dart
@@ -60,6 +60,10 @@
       defaultsTo: 'vm',
       allowed: allPlatforms.map((platform) => platform.identifier).toList(),
       allowMultiple: true);
+  parser.addOption("preset",
+      abbr: 'P',
+      help: 'The configuration preset(s) to use.',
+      allowMultiple: true);
   parser.addOption("concurrency",
       abbr: 'j',
       help: 'The number of concurrent test suites run.',
@@ -156,6 +160,7 @@
           (value) => new Timeout.parse(value)),
       pattern: pattern,
       platforms: ifParsed('platform')?.map(TestPlatform.find),
+      chosenPresets: ifParsed('preset'),
       paths: options.rest.isEmpty ? null : options.rest,
       includeTags: includeTags,
       excludeTags: excludeTags);
diff --git a/lib/src/runner/configuration/load.dart b/lib/src/runner/configuration/load.dart
index 93cc729..9a9778c 100644
--- a/lib/src/runner/configuration/load.dart
+++ b/lib/src/runner/configuration/load.dart
@@ -27,7 +27,7 @@
   var source = new File(path).readAsStringSync();
   var document = loadYamlNode(source, sourceUrl: p.toUri(path));
 
-  if (document.value == null) return new Configuration();
+  if (document.value == null) return Configuration.empty;
 
   if (document is! Map) {
     throw new SourceSpanFormatException(
@@ -75,14 +75,8 @@
 
     var timeout = _parseValue("timeout", (value) => new Timeout.parse(value));
 
-    var addTags = _getList("add_tags", (tagNode) {
-      _validate(tagNode, "Tags must be strings.", (value) => value is String);
-      _validate(
-          tagNode,
-          "Invalid tag. Tags must be (optionally hyphenated) Dart identifiers.",
-          (value) => value.contains(anchoredHyphenatedIdentifier));
-      return tagNode.value;
-    });
+    var addTags = _getList("add_tags",
+        (tagNode) => _parseIdentifierLike(tagNode, "Tag name"));
 
     var tags = _getMap("tags",
         key: (keyNode) => _parseNode(keyNode, "tags key",
@@ -108,6 +102,10 @@
           keyNode.span, _source);
     }, value: (valueNode) => _nestedConfig(valueNode, "on_os value"));
 
+    var presets = _getMap("presets",
+        key: (keyNode) => _parseIdentifierLike(keyNode, "presets key"),
+        value: (valueNode) => _nestedConfig(valueNode, "presets value"));
+
     var config = new Configuration(
         verboseTrace: verboseTrace,
         jsTrace: jsTrace,
@@ -117,7 +115,8 @@
         timeout: timeout,
         addTags: addTags,
         tags: tags,
-        onPlatform: onPlatform);
+        onPlatform: onPlatform,
+        presets: presets);
 
     var osConfig = onOS[currentOS];
     return osConfig == null ? config : config.merge(osConfig);
@@ -135,7 +134,8 @@
       _disallow("platforms");
       _disallow("paths");
       _disallow("filename");
-      return new Configuration();
+      _disallow("add_presets");
+      return Configuration.empty;
     }
 
     var reporter = _getString("reporter");
@@ -166,13 +166,17 @@
 
     var filename = _parseValue("filename", (value) => new Glob(value));
 
+    var chosenPresets = _getList("add_presets",
+        (presetNode) => _parseIdentifierLike(presetNode, "Preset name"));
+
     return new Configuration(
         reporter: reporter,
         pubServePort: pubServePort,
         concurrency: concurrency,
         platforms: platforms,
         paths: paths,
-        filename: filename);
+        filename: filename,
+        chosenPresets: chosenPresets);
   }
 
   /// Throws an exception with [message] if [test] returns `false` when passed
@@ -253,6 +257,15 @@
         value: (_, valueNode) => value(valueNode));
   }
 
+  String _parseIdentifierLike(YamlNode node, String name) {
+    _validate(node, "$name must be a string.", (value) => value is String);
+    _validate(
+        node,
+        "$name must be an (optionally hyphenated) Dart identifier.",
+        (value) => value.contains(anchoredHyphenatedIdentifier));
+    return node.value;
+  }
+
   /// Asserts that [node] is a string, passes its value to [parse], and returns
   /// the result.
   ///
@@ -287,7 +300,7 @@
   /// nested configuration. It defaults to [_runnerConfig].
   Configuration _nestedConfig(YamlNode node, String name,
       {bool runnerConfig}) {
-    if (node == null || node.value == null) return new Configuration();
+    if (node == null || node.value == null) return Configuration.empty;
 
     _validate(node, "$name must be a map.", (value) => value is Map);
     var loader = new _ConfigurationLoader(node, _source,
diff --git a/pubspec.yaml b/pubspec.yaml
index 8b5bb40..61d16e4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 0.12.12-dev
+version: 0.12.12
 author: Dart Team <misc@dartlang.org>
 description: A library for writing dart unit tests.
 homepage: https://github.com/dart-lang/test
diff --git a/test/runner/configuration/configuration_test.dart b/test/runner/configuration/configuration_test.dart
index 366d6c0..8fe9ba6 100644
--- a/test/runner/configuration/configuration_test.dart
+++ b/test/runner/configuration/configuration_test.dart
@@ -229,25 +229,43 @@
       });
     });
 
-    group("for tags", () {
-      test("merges each nested configuration", () {
-        var merged = new Configuration(
-          tags: {
-            new BooleanSelector.parse("foo"):
-                new Configuration(verboseTrace: true),
-            new BooleanSelector.parse("bar"): new Configuration(jsTrace: true)
-          }
-        ).merge(new Configuration(
-          tags: {
-            new BooleanSelector.parse("bar"): new Configuration(jsTrace: false),
-            new BooleanSelector.parse("baz"): new Configuration(skip: true)
-          }
-        ));
+    group("for sets", () {
+      test("if neither is defined, preserves the default", () {
+        var merged = new Configuration().merge(new Configuration());
+        expect(merged.addTags, isEmpty);
+        expect(merged.chosenPresets, isEmpty);
+      });
 
-        expect(merged.tags[new BooleanSelector.parse("foo")].verboseTrace,
-            isTrue);
-        expect(merged.tags[new BooleanSelector.parse("bar")].jsTrace, isFalse);
-        expect(merged.tags[new BooleanSelector.parse("baz")].skip, isTrue);
+      test("if only the old configuration's is defined, uses it", () {
+        var merged = new Configuration(
+                addTags: new Set.from(["foo", "bar"]),
+                chosenPresets: new Set.from(["baz", "bang"]))
+            .merge(new Configuration());
+
+        expect(merged.addTags, unorderedEquals(["foo", "bar"]));
+        expect(merged.chosenPresets, equals(["baz", "bang"]));
+      });
+
+      test("if only the new configuration's is defined, uses it", () {
+        var merged = new Configuration().merge(new Configuration(
+            addTags: new Set.from(["foo", "bar"]),
+            chosenPresets: new Set.from(["baz", "bang"])));
+
+        expect(merged.addTags, unorderedEquals(["foo", "bar"]));
+        expect(merged.chosenPresets, equals(["baz", "bang"]));
+      });
+
+      test("if both are defined, unions them", () {
+        var older = new Configuration(
+            addTags: new Set.from(["foo", "bar"]),
+            chosenPresets: new Set.from(["baz", "bang"]));
+        var newer = new Configuration(
+            addTags: new Set.from(["blip"]),
+            chosenPresets: new Set.from(["qux"]));
+        var merged = older.merge(newer);
+
+        expect(merged.addTags, unorderedEquals(["foo", "bar", "blip"]));
+        expect(merged.chosenPresets, equals(["baz", "bang", "qux"]));
       });
     });
 
@@ -286,26 +304,123 @@
       });
     });
 
-    group("for onPlatform", () {
+    group("for config maps", () {
       test("merges each nested configuration", () {
         var merged = new Configuration(
+          tags: {
+            new BooleanSelector.parse("foo"):
+                new Configuration(verboseTrace: true),
+            new BooleanSelector.parse("bar"): new Configuration(jsTrace: true)
+          },
           onPlatform: {
-            new PlatformSelector.parse("vm"): new Configuration(verboseTrace: true),
-            new PlatformSelector.parse("chrome"): new Configuration(jsTrace: true)
+            new PlatformSelector.parse("vm"):
+                new Configuration(verboseTrace: true),
+            new PlatformSelector.parse("chrome"):
+                new Configuration(jsTrace: true)
+          },
+          presets: {
+            "bang": new Configuration(verboseTrace: true),
+            "qux": new Configuration(jsTrace: true)
           }
         ).merge(new Configuration(
+          tags: {
+            new BooleanSelector.parse("bar"): new Configuration(jsTrace: false),
+            new BooleanSelector.parse("baz"): new Configuration(skip: true)
+          },
           onPlatform: {
-            new PlatformSelector.parse("chrome"): new Configuration(jsTrace: false),
+            new PlatformSelector.parse("chrome"):
+                new Configuration(jsTrace: false),
             new PlatformSelector.parse("firefox"): new Configuration(skip: true)
+          },
+          presets: {
+            "qux": new Configuration(jsTrace: false),
+            "zap": new Configuration(skip: true)
           }
         ));
 
+        expect(merged.tags[new BooleanSelector.parse("foo")].verboseTrace,
+            isTrue);
+        expect(merged.tags[new BooleanSelector.parse("bar")].jsTrace, isFalse);
+        expect(merged.tags[new BooleanSelector.parse("baz")].skip, isTrue);
+
         expect(merged.onPlatform[new PlatformSelector.parse("vm")].verboseTrace,
             isTrue);
         expect(merged.onPlatform[new PlatformSelector.parse("chrome")].jsTrace,
             isFalse);
         expect(merged.onPlatform[new PlatformSelector.parse("firefox")].skip,
             isTrue);
+
+        expect(merged.presets["bang"].verboseTrace, isTrue);
+        expect(merged.presets["qux"].jsTrace, isFalse);
+        expect(merged.presets["zap"].skip, isTrue);
+      });
+    });
+
+    group("for presets", () {
+      test("automatically resolves a matching chosen preset", () {
+        var configuration = new Configuration(
+            presets: {"foo": new Configuration(verboseTrace: true)},
+            chosenPresets: ["foo"]);
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["foo"]));
+        expect(configuration.knownPresets, equals(["foo"]));
+        expect(configuration.verboseTrace, isTrue);
+      });
+
+      test("resolves a chosen presets in order", () {
+        var configuration = new Configuration(
+            presets: {
+              "foo": new Configuration(verboseTrace: true),
+              "bar": new Configuration(verboseTrace: false)
+            },
+            chosenPresets: ["foo", "bar"]);
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["foo", "bar"]));
+        expect(configuration.knownPresets, unorderedEquals(["foo", "bar"]));
+        expect(configuration.verboseTrace, isFalse);
+
+        configuration = new Configuration(
+            presets: {
+              "foo": new Configuration(verboseTrace: true),
+              "bar": new Configuration(verboseTrace: false)
+            },
+            chosenPresets: ["bar", "foo"]);
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["bar", "foo"]));
+        expect(configuration.knownPresets, unorderedEquals(["foo", "bar"]));
+        expect(configuration.verboseTrace, isTrue);
+      });
+
+      test("ignores inapplicable chosen presets", () {
+        var configuration = new Configuration(
+            presets: {},
+            chosenPresets: ["baz"]);
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["baz"]));
+        expect(configuration.knownPresets, equals(isEmpty));
+      });
+
+      test("resolves presets through merging", () {
+        var configuration = new Configuration(presets: {
+          "foo": new Configuration(verboseTrace: true)
+        }).merge(new Configuration(chosenPresets: ["foo"]));
+
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["foo"]));
+        expect(configuration.knownPresets, equals(["foo"]));
+        expect(configuration.verboseTrace, isTrue);
+      });
+
+      test("preserves known presets through merging", () {
+        var configuration = new Configuration(presets: {
+          "foo": new Configuration(verboseTrace: true)
+        }, chosenPresets: ["foo"])
+            .merge(new Configuration());
+
+        expect(configuration.presets, isEmpty);
+        expect(configuration.chosenPresets, equals(["foo"]));
+        expect(configuration.knownPresets, equals(["foo"]));
+        expect(configuration.verboseTrace, isTrue);
       });
     });
   });
diff --git a/test/runner/configuration/platform_test.dart b/test/runner/configuration/platform_test.dart
index f9bef94..373a08c 100644
--- a/test/runner/configuration/platform_test.dart
+++ b/test/runner/configuration/platform_test.dart
@@ -180,7 +180,7 @@
       test.shouldExit(0);
     });
 
-    test("doesn't OS-specific configuration on a non-matching OS", () {
+    test("doesn't apply OS-specific configuration on a non-matching OS", () {
       d.file("dart_test.yaml", JSON.encode({
         "on_os": {
           otherOS: {"filename": "test_*.dart"}
diff --git a/test/runner/configuration/presets_test.dart b/test/runner/configuration/presets_test.dart
new file mode 100644
index 0000000..6491ad7
--- /dev/null
+++ b/test/runner/configuration/presets_test.dart
@@ -0,0 +1,450 @@
+// 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.
+
+@TestOn("vm")
+
+import 'dart:convert';
+
+import 'package:scheduled_test/descriptor.dart' as d;
+import 'package:scheduled_test/scheduled_stream.dart';
+import 'package:scheduled_test/scheduled_test.dart';
+
+import 'package:test/src/util/exit_codes.dart' as exit_codes;
+import 'package:test/src/util/io.dart';
+
+import '../../io.dart';
+
+void main() {
+  useSandbox();
+
+  group("presets", () {
+    test("don't do anything by default", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      runTest(["test.dart"]).shouldExit(0);
+    });
+
+    test("can be selected on the command line", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["-P", "foo", "test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("multiple presets can be selected", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"},
+          "bar": {"paths": ["test.dart"]}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["-P", "foo,bar"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("the latter preset takes precedence", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"},
+          "bar": {"timeout": "30s"}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      runTest(["-P", "foo,bar", "test.dart"]).shouldExit(0);
+
+      var test = runTest(["-P", "bar,foo", "test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("a preset takes precedence over the base configuration", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"}
+        },
+        "timeout": "30s"
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["-P", "foo", "test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "30s"}
+        },
+        "timeout": "00s"
+      })).create();
+
+      runTest(["-P", "foo", "test.dart"]).shouldExit(0);
+    });
+
+    test("a nested preset is activated", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "tags": {
+          "foo": {
+            "presets": {
+              "bar": {"timeout": "0s"}
+            },
+          },
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test 1", () => new Future.delayed(Duration.ZERO), tags: "foo");
+          test("test 2", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["-P", "bar", "test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "+1 -1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "30s"}
+        },
+        "timeout": "00s"
+      })).create();
+
+      runTest(["-P", "foo", "test.dart"]).shouldExit(0);
+    });
+  });
+
+  group("add_presets", () {
+    test("selects a preset", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"}
+        },
+        "add_presets": ["foo"]
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("applies presets in selection order", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"},
+          "bar": {"timeout": "30s"}
+        },
+        "add_presets": ["foo", "bar"]
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      runTest(["test.dart"]).shouldExit(0);
+
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"timeout": "0s"},
+          "bar": {"timeout": "30s"}
+        },
+        "add_presets": ["bar", "foo"]
+      })).create();
+
+      var test = runTest(["test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("allows preset inheritance via add_presets", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"add_presets": ["bar"]},
+          "bar": {"timeout": "0s"}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () => new Future.delayed(Duration.ZERO));
+        }
+      """).create();
+
+      var test = runTest(["-P", "foo", "test.dart"]);
+      test.stdout.expect(containsInOrder([
+        "-1: test",
+        "-1: Some tests failed."
+      ]));
+      test.shouldExit(1);
+    });
+
+    test("allows circular preset inheritance via add_presets", () {
+      d.file("dart_test.yaml", JSON.encode({
+        "presets": {
+          "foo": {"add_presets": ["bar"]},
+          "bar": {"add_presets": ["foo"]}
+        }
+      })).create();
+
+      d.file("test.dart", """
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () {});
+        }
+      """).create();
+
+      runTest(["-P", "foo", "test.dart"]).shouldExit(0);
+    });
+  });
+
+  group("errors", () {
+    group("presets", () {
+      test("rejects an invalid preset type", () {
+        d.file("dart_test.yaml", '{"presets": {12: null}}').create();
+
+        var test = runTest([]);
+        test.stderr.expect(containsInOrder([
+          "presets key must be a string",
+          "^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects an invalid preset name", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "presets": {"foo bar": null}
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(containsInOrder([
+          "presets key must be an (optionally hyphenated) Dart identifier.",
+          "^^^^^^^^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects an invalid preset map", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "presets": 12
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(containsInOrder([
+          "presets must be a map",
+          "^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects an invalid preset configuration", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "presets": {"foo": {"timeout": "12p"}}
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(containsInOrder([
+          "Invalid timeout: expected unit",
+          "^^^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects runner configuration in a non-runner context", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "tags": {
+            "foo": {
+              "presets": {"bar": {"filename": "*_blorp.dart"}}
+            }
+          }
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(containsInOrder([
+          "filename isn't supported here.",
+          "^^^^^^^^^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("fails if an undefined preset is passed", () {
+        var test = runTest(["-P", "foo"]);
+        test.stderr.expect(consumeThrough(contains('Undefined preset "foo".')));
+        test.shouldExit(exit_codes.usage);
+      });
+
+      test("fails if an undefined preset is added", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "add_presets": ["foo", "bar"]
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(consumeThrough(contains(
+            'Undefined presets "foo" and "bar".')));
+        test.shouldExit(exit_codes.usage);
+      });
+
+      test("fails if an undefined preset is added in a nested context", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "on_os": {
+            currentOS.identifier: {
+              "add_presets": ["bar"]
+            }
+          }
+        })).create();
+
+        var test = runTest([]);
+        test.stderr.expect(consumeThrough(contains('Undefined preset "bar".')));
+        test.shouldExit(exit_codes.usage);
+      });
+    });
+
+    group("add_presets", () {
+      test("rejects an invalid list type", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "add_presets": "foo"
+        })).create();
+
+        var test = runTest(["test.dart"]);
+        test.stderr.expect(containsInOrder([
+          "add_presets must be a list",
+          "^^^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects an invalid preset type", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "add_presets": [12]
+        })).create();
+
+        var test = runTest(["test.dart"]);
+        test.stderr.expect(containsInOrder([
+          "Preset name must be a string",
+          "^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+
+      test("rejects an invalid preset name", () {
+        d.file("dart_test.yaml", JSON.encode({
+          "add_presets": ["foo bar"]
+        })).create();
+
+        var test = runTest(["test.dart"]);
+        test.stderr.expect(containsInOrder([
+          "Preset name must be an (optionally hyphenated) Dart identifier.",
+          "^^^^^^^^^"
+        ]));
+        test.shouldExit(exit_codes.data);
+      });
+    });
+  });
+}
diff --git a/test/runner/configuration/tags_test.dart b/test/runner/configuration/tags_test.dart
index cddbf76..5fd432e 100644
--- a/test/runner/configuration/tags_test.dart
+++ b/test/runner/configuration/tags_test.dart
@@ -158,7 +158,7 @@
         test.shouldExit(exit_codes.data);
       });
 
-      test("rejects an inavlid tag map", () {
+      test("rejects an invalid tag map", () {
         d.file("dart_test.yaml", JSON.encode({
           "tags": 12
         })).create();
@@ -219,7 +219,7 @@
 
         var test = runTest(["test.dart"]);
         test.stderr.expect(containsInOrder([
-          "Tags must be strings",
+          "Tag name must be a string",
           "^^"
         ]));
         test.shouldExit(exit_codes.data);
@@ -232,7 +232,7 @@
 
         var test = runTest(["test.dart"]);
         test.stderr.expect(containsInOrder([
-          "Invalid tag. Tags must be (optionally hyphenated) Dart identifiers.",
+          "Tag name must be an (optionally hyphenated) Dart identifier.",
           "^^^^^^^^^"
         ]));
         test.shouldExit(exit_codes.data);
diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart
index 9eb92cb..5c37bf0 100644
--- a/test/runner/runner_test.dart
+++ b/test/runner/runner_test.dart
@@ -62,6 +62,7 @@
 -p, --platform                 The platform(s) on which to run the tests.
                                $_browsers
 
+-P, --preset                   The configuration preset(s) to use.
 -j, --concurrency=<threads>    The number of concurrent test suites run.
                                (defaults to "$_defaultConcurrency")