From 16f8247a8e5f84a78a354bf158cd86dff42ecacb Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Mon, 20 Apr 2015 19:19:18 -0700
Subject: [PATCH] Add an "onPlatform" parameter to test() and group().

This allows platform-specific metadata to be declared.

See #75

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1097183003
---
 lib/src/backend/declarer.dart               |  56 +-------
 lib/src/backend/invoker.dart                |   6 +
 lib/src/backend/metadata.dart               | 151 ++++++++++++++++----
 lib/src/backend/suite.dart                  |  11 +-
 lib/src/backend/test.dart                   |   4 +
 lib/src/runner/browser/browser_manager.dart |  18 ++-
 lib/src/runner/browser/iframe_listener.dart |  12 +-
 lib/src/runner/browser/iframe_test.dart     |   6 +
 lib/src/runner/browser/server.dart          |   2 +-
 lib/src/runner/loader.dart                  |   4 +-
 lib/src/runner/vm/isolate_listener.dart     |   7 +-
 lib/src/runner/vm/isolate_test.dart         |   8 +-
 lib/src/utils.dart                          |  14 ++
 lib/test.dart                               |  54 ++++++-
 test/backend/metadata_test.dart             |  66 +++++++++
 test/runner/browser/runner_test.dart        |  94 ++++++++++++
 test/runner/runner_test.dart                |  94 ++++++++++++
 17 files changed, 502 insertions(+), 105 deletions(-)
 create mode 100644 test/backend/metadata_test.dart

diff --git a/lib/src/backend/declarer.dart b/lib/src/backend/declarer.dart
index 9912274c..46e88629 100644
--- a/lib/src/backend/declarer.dart
+++ b/lib/src/backend/declarer.dart
@@ -27,30 +27,16 @@ class Declarer {
   Declarer();
 
   /// Defines a test case with the given description and body.
-  ///
-  /// The description will be added to the descriptions of any surrounding
-  /// [group]s.
-  ///
-  /// If [testOn] is passed, it's parsed as a [PlatformSelector], and the test
-  /// will only be run on matching platforms.
-  ///
-  /// If [timeout] is passed, it's used to modify or replace the default timeout
-  /// of 30 seconds. Timeout modifications take precedence in suite-group-test
-  /// order, so [timeout] will also modify any timeouts set on the group or
-  /// suite.
-  ///
-  /// If [skip] is a String or `true`, the test is skipped. If it's a String, it
-  /// should explain why the test is skipped; this reason will be printed
-  /// instead of running the test.
   void test(String description, body(), {String testOn, Timeout timeout,
-      skip}) {
+      skip, Map<String, dynamic> onPlatform}) {
     // TODO(nweiz): Once tests have begun running, throw an error if [test] is
     // called.
     var prefix = _group.description;
     if (prefix != null) description = "$prefix $description";
 
-    var metadata = _group.metadata.merge(
-        new Metadata.parse(testOn: testOn, timeout: timeout, skip: skip));
+    var metadata = _group.metadata.merge(new Metadata.parse(
+        testOn: testOn, timeout: timeout, skip: skip, onPlatform: onPlatform));
+
     var group = _group;
     _tests.add(new LocalTest(description, metadata, () {
       // TODO(nweiz): It might be useful to throw an error here if a test starts
@@ -61,28 +47,12 @@ class Declarer {
   }
 
   /// Creates a group of tests.
-  ///
-  /// A group's description is included in the descriptions of any tests or
-  /// sub-groups it contains. [setUp] and [tearDown] are also scoped to the
-  /// containing group.
-  ///
-  /// If [testOn] is passed, it's parsed as a [PlatformSelector], and any tests
-  /// in the group will only be run on matching platforms.
-  ///
-  /// If [timeout] is passed, it's used to modify or replace the default timeout
-  /// of 30 seconds. Timeout modifications take precedence in suite-group-test
-  /// order, so [timeout] will also modify any timeouts set on the group or
-  /// suite.
-  ///
-  /// If [skip] is a String or `true`, the group is skipped. If it's a String,
-  /// it should explain why the group is skipped; this reason will be printed
-  /// instead of running the group's tests.
   void group(String description, void body(), {String testOn,
-      Timeout timeout, skip}) {
+      Timeout timeout, skip, Map<String, dynamic> onPlatform}) {
     var oldGroup = _group;
 
     var metadata = new Metadata.parse(
-        testOn: testOn, timeout: timeout, skip: skip);
+        testOn: testOn, timeout: timeout, skip: skip, onPlatform: onPlatform);
 
     // Don' load the tests for a skipped group.
     if (metadata.skip) {
@@ -99,13 +69,6 @@ class Declarer {
   }
 
   /// Registers a function to be run before tests.
-  ///
-  /// This function will be called before each test is run. [callback] may be
-  /// asynchronous; if so, it must return a [Future].
-  ///
-  /// If this is called within a [group], it applies only to tests in that
-  /// group. [callback] will be run after any set-up callbacks in parent groups
-  /// or at the top level.
   void setUp(callback()) {
     if (_group.setUp != null) {
       throw new StateError("setUp() may not be called multiple times for the "
@@ -116,13 +79,6 @@ class Declarer {
   }
 
   /// Registers a function to be run after tests.
-  ///
-  /// This function will be called after each test is run. [callback] may be
-  /// asynchronous; if so, it must return a [Future].
-  ///
-  /// If this is called within a [group], it applies only to tests in that
-  /// group. [callback] will be run before any tear-down callbacks in parent
-  /// groups or at the top level.
   void tearDown(callback()) {
     if (_group.tearDown != null) {
       throw new StateError("tearDown() may not be called multiple times for "
diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart
index 2ff17725..81a931e4 100644
--- a/lib/src/backend/invoker.dart
+++ b/lib/src/backend/invoker.dart
@@ -42,6 +42,12 @@ class LocalTest implements Test {
     var invoker = new Invoker._(suite, this);
     return invoker.liveTest;
   }
+
+  Test change({String name, Metadata metadata}) {
+    if (name == null) name = this.name;
+    if (metadata == null) metadata = this.metadata;
+    return new LocalTest(name, metadata, _body, tearDown: _tearDown);
+  }
 }
 
 /// The class responsible for managing the lifecycle of a single local test.
diff --git a/lib/src/backend/metadata.dart b/lib/src/backend/metadata.dart
index ecce4404..0bcf5f88 100644
--- a/lib/src/backend/metadata.dart
+++ b/lib/src/backend/metadata.dart
@@ -4,7 +4,13 @@
 
 library test.backend.metadata;
 
+import 'dart:collection';
+
+import '../backend/operating_system.dart';
+import '../backend/test_platform.dart';
+import '../frontend/skip.dart';
 import '../frontend/timeout.dart';
+import '../utils.dart';
 import 'platform_selector.dart';
 
 /// Metadata for a test or test suite.
@@ -24,26 +30,81 @@ class Metadata {
   /// The reason the test or suite should be skipped, if given.
   final String skipReason;
 
+  /// Platform-specific metadata.
+  ///
+  /// Each key identifies a platform, and its value identifies the specific
+  /// metadata for that platform. These can be applied by calling [forPlatform].
+  final Map<PlatformSelector, Metadata> onPlatform;
+
+  /// Parses a user-provided map into the value for [onPlatform].
+  static Map<PlatformSelector, Metadata> _parseOnPlatform(
+      Map<String, dynamic> onPlatform) {
+    if (onPlatform == null) return {};
+
+    var result = {};
+    onPlatform.forEach((platform, metadata) {
+      if (metadata is Timeout || metadata is Skip) {
+        metadata = [metadata];
+      } else if (metadata is! List) {
+        throw new ArgumentError('Metadata for platform "$platform" must be a '
+            'Timeout, Skip, or List of those; was "$metadata".');
+      }
+
+      var selector = new PlatformSelector.parse(platform);
+
+      var timeout;
+      var skip;
+      for (var metadatum in metadata) {
+        if (metadatum is Timeout) {
+          if (timeout != null) {
+            throw new ArgumentError('Only a single Timeout may be declared for '
+                '"$platform".');
+          }
+
+          timeout = metadatum;
+        } else if (metadatum is Skip) {
+          if (skip != null) {
+            throw new ArgumentError('Only a single Skip may be declared for '
+                '"$platform".');
+          }
+
+          skip = metadatum.reason == null ? true : metadatum.reason;
+        } else {
+          throw new ArgumentError('Metadata for platform "$platform" must be a '
+              'Timeout, Skip, or List of those; was "$metadata".');
+        }
+      }
+
+      result[selector] = new Metadata.parse(timeout: timeout, skip: skip);
+    });
+    return result;
+  }
+
   /// Creates new Metadata.
   ///
   /// [testOn] defaults to [PlatformSelector.all].
   Metadata({PlatformSelector testOn, Timeout timeout, bool skip: false,
-          this.skipReason})
+          this.skipReason, Map<PlatformSelector, Metadata> onPlatform})
       : testOn = testOn == null ? PlatformSelector.all : testOn,
         timeout = timeout == null ? const Timeout.factor(1) : timeout,
-        skip = skip;
+        skip = skip,
+        onPlatform = onPlatform == null
+            ? const {}
+            : new UnmodifiableMapView(onPlatform);
 
-  /// Creates a new Metadata, but with fields parsed from strings where
-  /// applicable.
+  /// Creates a new Metadata, but with fields parsed from caller-friendly values
+  /// where applicable.
   ///
   /// Throws a [FormatException] if any field is invalid.
-  Metadata.parse({String testOn, Timeout timeout, skip})
+  Metadata.parse({String testOn, Timeout timeout, skip,
+          Map<String, dynamic> onPlatform})
       : testOn = testOn == null
             ? PlatformSelector.all
             : new PlatformSelector.parse(testOn),
         timeout = timeout == null ? const Timeout.factor(1) : timeout,
         skip = skip != null && skip != false,
-        skipReason = skip is String ? skip : null {
+        skipReason = skip is String ? skip : null,
+        onPlatform = _parseOnPlatform(onPlatform) {
     if (skip != null && skip is! String && skip is! bool) {
       throw new ArgumentError(
           '"skip" must be a String or a bool, was "$skip".');
@@ -52,15 +113,18 @@ class Metadata {
 
   /// Dezerializes the result of [Metadata.serialize] into a new [Metadata].
   Metadata.deserialize(serialized)
-      : this.parse(
-          testOn: serialized['testOn'],
-          timeout: serialized['timeout']['duration'] == null
-              ? new Timeout.factor(serialized['timeout']['scaleFactor'])
-              : new Timeout(new Duration(
-                  microseconds: serialized['timeout']['duration'])),
-          skip: serialized['skipReason'] == null
-              ? serialized['skip']
-              : serialized['skipReason']);
+      : testOn = serialized['testOn'] == null
+            ? PlatformSelector.all
+            : new PlatformSelector.parse(serialized['testOn']),
+        timeout = serialized['timeout']['duration'] == null
+            ? new Timeout.factor(serialized['timeout']['scaleFactor'])
+            : new Timeout(new Duration(
+                microseconds: serialized['timeout']['duration'])),
+        skip = serialized['skip'],
+        skipReason = serialized['skipReason'],
+        onPlatform = new Map.fromIterable(serialized['onPlatform'],
+            key: (pair) => new PlatformSelector.parse(pair.first),
+            value: (pair) => new Metadata.deserialize(pair.last));
 
   /// Return a new [Metadata] that merges [this] with [other].
   ///
@@ -70,19 +134,52 @@ class Metadata {
           testOn: testOn.intersect(other.testOn),
           timeout: timeout.merge(other.timeout),
           skip: skip || other.skip,
-          skipReason: other.skipReason == null ? skipReason : other.skipReason);
+          skipReason: other.skipReason == null ? skipReason : other.skipReason,
+          onPlatform: mergeMaps(onPlatform, other.onPlatform));
+
+  /// Returns a copy of [this] with the given fields changed.
+  Metadata change({PlatformSelector testOn, Timeout timeout, bool skip,
+      String skipReason, Map<PlatformSelector, Metadata> onPlatform}) {
+    if (testOn == null) testOn = this.testOn;
+    if (timeout == null) timeout = this.timeout;
+    if (skip == null) skip = this.skip;
+    if (skipReason == null) skipReason = this.skipReason;
+    if (onPlatform == null) onPlatform = this.onPlatform;
+    return new Metadata(testOn: testOn, timeout: timeout, skip: skip,
+        skipReason: skipReason, onPlatform: onPlatform);
+  }
+
+  /// Returns a copy of [this] with all platform-specific metadata from
+  /// [onPlatform] resolved.
+  Metadata forPlatform(TestPlatform platform, {OperatingSystem os}) {
+    var metadata = this;
+    onPlatform.forEach((platformSelector, platformMetadata) {
+      if (!platformSelector.evaluate(platform, os: os)) return;
+      metadata = metadata.merge(platformMetadata);
+    });
+    return metadata.change(onPlatform: {});
+  }
 
   /// Serializes [this] into a JSON-safe object that can be deserialized using
   /// [new Metadata.deserialize].
-  serialize() => {
-    'testOn': testOn == PlatformSelector.all ? null : testOn.toString(),
-    'timeout': {
-      'duration': timeout.duration == null
-          ? null
-          : timeout.duration.inMicroseconds,
-      'scaleFactor': timeout.scaleFactor
-    },
-    'skip': skip,
-    'skipReason': skipReason
-  };
+  serialize() {
+    // Make this a list to guarantee that the order is preserved.
+    var serializedOnPlatform = [];
+    onPlatform.forEach((key, value) {
+      serializedOnPlatform.add([key.toString(), value.serialize()]);
+    });
+
+    return {
+      'testOn': testOn == PlatformSelector.all ? null : testOn.toString(),
+      'timeout': {
+        'duration': timeout.duration == null
+            ? null
+            : timeout.duration.inMicroseconds,
+        'scaleFactor': timeout.scaleFactor
+      },
+      'skip': skip,
+      'skipReason': skipReason,
+      'onPlatform': serializedOnPlatform
+    };
+  }
 }
diff --git a/lib/src/backend/suite.dart b/lib/src/backend/suite.dart
index 78cb8742..e7596a87 100644
--- a/lib/src/backend/suite.dart
+++ b/lib/src/backend/suite.dart
@@ -33,14 +33,17 @@ class Suite {
       : metadata = metadata == null ? new Metadata() : metadata,
         tests = new UnmodifiableListView<Test>(tests.toList());
 
-  /// Returns a new suite that only contains tests that are valid for the given
-  /// [platform] and [os].
+  /// Returns a view of this suite for the given [platform] and [os].
   ///
-  /// If the suite itself is invalid for [platform] and [os], returns `null`.
-  Suite filter(TestPlatform platform, {OperatingSystem os}) {
+  /// This filters out tests that are invalid for [platform] and [os] and
+  /// resolves platform-specific metadata. If the suite itself is invalid for
+  /// [platform] and [os], returns `null`.
+  Suite forPlatform(TestPlatform platform, {OperatingSystem os}) {
     if (!metadata.testOn.evaluate(platform, os: os)) return null;
     return change(tests: tests.where((test) {
       return test.metadata.testOn.evaluate(platform, os: os);
+    }).map((test) {
+      return test.change(metadata: test.metadata.forPlatform(platform, os: os));
     }));
   }
 
diff --git a/lib/src/backend/test.dart b/lib/src/backend/test.dart
index 85ca2c7d..0334a8f4 100644
--- a/lib/src/backend/test.dart
+++ b/lib/src/backend/test.dart
@@ -25,4 +25,8 @@ abstract class Test {
   ///
   /// [suite] is the suite within which this test is being run.
   LiveTest load(Suite suite);
+
+  /// Returns a new copy of this Test with the given [name] and [metadata], if
+  /// passed.
+  Test change({String name, Metadata metadata});
 }
diff --git a/lib/src/runner/browser/browser_manager.dart b/lib/src/runner/browser/browser_manager.dart
index 380be41f..b20da079 100644
--- a/lib/src/runner/browser/browser_manager.dart
+++ b/lib/src/runner/browser/browser_manager.dart
@@ -11,6 +11,7 @@ import 'package:http_parser/http_parser.dart';
 
 import '../../backend/metadata.dart';
 import '../../backend/suite.dart';
+import '../../backend/test_platform.dart';
 import '../../util/multi_channel.dart';
 import '../../util/remote_exception.dart';
 import '../../utils.dart';
@@ -22,14 +23,17 @@ import 'iframe_test.dart';
 /// This is in charge of telling the browser which test suites to load and
 /// converting its responses into [Suite] objects.
 class BrowserManager {
+  /// The browser that this is managing.
+  final TestPlatform browser;
+
   /// The channel used to communicate with the browser.
   ///
   /// This is connected to a page running `static/host.dart`.
   final MultiChannel _channel;
 
-  /// Creates a new BrowserManager that communicates with a browser over
+  /// Creates a new BrowserManager that communicates with [browser] over
   /// [webSocket].
-  BrowserManager(CompatibleWebSocket webSocket)
+  BrowserManager(this.browser, CompatibleWebSocket webSocket)
       : _channel = new MultiChannel(
           webSocket.map(JSON.decode),
           mapSink(webSocket, JSON.encode));
@@ -40,8 +44,10 @@ class BrowserManager {
   /// suite. [path] is the path of the original test suite file, which is used
   /// for reporting. [metadata] is the parsed metadata for the test suite.
   Future<Suite> loadSuite(String path, Uri url, Metadata metadata) {
-    url = url.replace(
-        fragment: Uri.encodeFull(JSON.encode(metadata.serialize())));
+    url = url.replace(fragment: Uri.encodeFull(JSON.encode({
+      "metadata": metadata.serialize(),
+      "browser": browser.identifier
+    })));
 
     var suiteChannel = _channel.virtualChannel();
     _channel.sink.add({
@@ -59,7 +65,9 @@ class BrowserManager {
     return maybeFirst(suiteChannel.stream)
         .timeout(new Duration(seconds: 7), onTimeout: () {
       throw new LoadException(
-          path, "Timed out waiting for the test suite to connect.");
+          path,
+          "Timed out waiting for the test suite to connect on "
+              "${browser.name}.");
     }).then((response) {
       if (response == null) return null;
 
diff --git a/lib/src/runner/browser/iframe_listener.dart b/lib/src/runner/browser/iframe_listener.dart
index 468e3acc..b345f379 100644
--- a/lib/src/runner/browser/iframe_listener.dart
+++ b/lib/src/runner/browser/iframe_listener.dart
@@ -12,6 +12,7 @@ import '../../backend/declarer.dart';
 import '../../backend/metadata.dart';
 import '../../backend/suite.dart';
 import '../../backend/test.dart';
+import '../../backend/test_platform.dart';
 import '../../util/multi_channel.dart';
 import '../../util/remote_exception.dart';
 import '../../utils.dart';
@@ -66,12 +67,13 @@ class IframeListener {
     }
 
     var url = Uri.parse(window.location.href);
-    var metadata = url.hasFragment
-        ? new Metadata.deserialize(JSON.decode(Uri.decodeFull(url.fragment)))
-        : new Metadata();
+    var message = JSON.decode(Uri.decodeFull(url.fragment));
+    var metadata = new Metadata.deserialize(message['metadata']);
+    var browser = TestPlatform.find(message['browser']);
 
-    new IframeListener._(new Suite(declarer.tests, metadata: metadata))
-        ._listen(channel);
+    var suite = new Suite(declarer.tests, metadata: metadata)
+        .forPlatform(browser);
+    new IframeListener._(suite)._listen(channel);
   }
 
   /// Constructs a [MultiChannel] wrapping the `postMessage` communication with
diff --git a/lib/src/runner/browser/iframe_test.dart b/lib/src/runner/browser/iframe_test.dart
index 6b799419..bb54e786 100644
--- a/lib/src/runner/browser/iframe_test.dart
+++ b/lib/src/runner/browser/iframe_test.dart
@@ -60,4 +60,10 @@ class IframeTest implements Test {
     });
     return controller.liveTest;
   }
+
+  Test change({String name, Metadata metadata}) {
+    if (name == null) name = this.name;
+    if (metadata == null) metadata = this.metadata;
+    return new IframeTest(name, metadata, _channel);
+  }
 }
diff --git a/lib/src/runner/browser/server.dart b/lib/src/runner/browser/server.dart
index 9c58885e..2a8d08e8 100644
--- a/lib/src/runner/browser/server.dart
+++ b/lib/src/runner/browser/server.dart
@@ -353,7 +353,7 @@ void main() {
     // value and [browser.onError].
     _browserManagers[platform] = completer.future.catchError((_) {});
     var path = _webSocketHandler.create(webSocketHandler((webSocket) {
-      completer.complete(new BrowserManager(webSocket));
+      completer.complete(new BrowserManager(platform, webSocket));
     }));
 
     var webSocketUrl = url.replace(scheme: 'ws').resolve(path);
diff --git a/lib/src/runner/loader.dart b/lib/src/runner/loader.dart
index 4b1f3d74..a6db18d7 100644
--- a/lib/src/runner/loader.dart
+++ b/lib/src/runner/loader.dart
@@ -148,9 +148,7 @@ class Loader {
         assert(platform.isBrowser);
         return _loadBrowserFile(path, platform, metadata);
       }).then((suite) {
-        if (suite == null) return;
-
-        controller.add(suite.filter(platform, os: currentOS));
+        if (suite != null) controller.add(suite);
       }).catchError(controller.addError);
     }).then((_) => controller.close());
 
diff --git a/lib/src/runner/vm/isolate_listener.dart b/lib/src/runner/vm/isolate_listener.dart
index b40140e7..ed23a1d1 100644
--- a/lib/src/runner/vm/isolate_listener.dart
+++ b/lib/src/runner/vm/isolate_listener.dart
@@ -11,6 +11,8 @@ import '../../backend/declarer.dart';
 import '../../backend/metadata.dart';
 import '../../backend/suite.dart';
 import '../../backend/test.dart';
+import '../../backend/test_platform.dart';
+import '../../util/io.dart';
 import '../../util/remote_exception.dart';
 import '../../utils.dart';
 
@@ -59,8 +61,9 @@ class IsolateListener {
       return;
     }
 
-    new IsolateListener._(new Suite(declarer.tests, metadata: metadata))
-        ._listen(sendPort);
+    var suite = new Suite(declarer.tests, metadata: metadata)
+        .forPlatform(TestPlatform.vm, os: currentOS);
+    new IsolateListener._(suite)._listen(sendPort);
   }
 
   /// Sends a message over [sendPort] indicating that the tests failed to load.
diff --git a/lib/src/runner/vm/isolate_test.dart b/lib/src/runner/vm/isolate_test.dart
index 61a74cc3..46b2048f 100644
--- a/lib/src/runner/vm/isolate_test.dart
+++ b/lib/src/runner/vm/isolate_test.dart
@@ -16,7 +16,7 @@ import '../../backend/test.dart';
 import '../../util/remote_exception.dart';
 
 /// A test in another isolate.
-class IsolateTest implements Test {
+class IsolateTest extends Test {
   final String name;
   final Metadata metadata;
 
@@ -81,4 +81,10 @@ class IsolateTest implements Test {
     });
     return controller.liveTest;
   }
+
+  Test change({String name, Metadata metadata}) {
+    if (name == null) name = this.name;
+    if (metadata == null) metadata = this.metadata;
+    return new IsolateTest(name, metadata, _sendPort);
+  }
 }
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 59edb665..3b8111d0 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -98,6 +98,20 @@ List flatten(Iterable nested) {
   return result;
 }
 
+/// Returns a new map with all values in both [map1] and [map2].
+///
+/// If there are conflicting keys, [map2]'s value wins.
+Map mergeMaps(Map map1, Map map2) {
+  var result = {};
+  map1.forEach((key, value) {
+    result[key] = value;
+  });
+  map2.forEach((key, value) {
+    result[key] = value;
+  });
+  return result;
+}
+
 /// Returns a sink that maps events sent to [original] using [fn].
 StreamSink mapSink(StreamSink original, fn(event)) {
   var controller = new StreamController(sync: true);
diff --git a/lib/test.dart b/lib/test.dart
index 6abe99ec..ed2f058b 100644
--- a/lib/test.dart
+++ b/lib/test.dart
@@ -50,10 +50,10 @@ Declarer get _declarer {
   _globalDeclarer = new Declarer();
   scheduleMicrotask(() {
     var suite =
-      new Suite(_globalDeclarer.tests,
-            path: p.prettyUri(Uri.base),
-            platform: "VM")
-      .filter(TestPlatform.vm, os: currentOSGuess);
+        new Suite(_globalDeclarer.tests,
+              path: p.prettyUri(Uri.base),
+              platform: "VM")
+        .forPlatform(TestPlatform.vm, os: currentOSGuess);
     // TODO(nweiz): Set the exit code on the VM when issue 6943 is fixed.
     new NoIoCompactReporter([suite], color: true).run();
   });
@@ -77,10 +77,30 @@ Declarer get _declarer {
 /// If [skip] is a String or `true`, the test is skipped. If it's a String, it
 /// should explain why the test is skipped; this reason will be printed instead
 /// of running the test.
+///
+/// [onPlatform] allows tests to be configured on a platform-by-platform
+/// basis. It's a map from strings that are parsed as [PlatformSelector]s to
+/// annotation classes: [Timeout], [Skip], or lists of those. These
+/// annotations apply only on the given platforms. For example:
+///
+///     test("potentially slow test", () {
+///       // ...
+///     }, onPlatform: {
+///       // This test is especially slow on Windows.
+///       "windows": new Timeout.factor(2),
+///       "browser": [
+///         new Skip("TODO: add browser support"),
+///         // This will be slow on browsers once it works on them.
+///         new Timeout.factor(2)
+///       ]
+///     });
+///
+/// If multiple platforms match, the annotations apply in order as through
+/// they were in nested groups.
 void test(String description, body(), {String testOn, Timeout timeout,
-        skip}) =>
+        skip, Map<String, dynamic> onPlatform}) =>
     _declarer.test(description, body,
-        testOn: testOn, timeout: timeout, skip: skip);
+        testOn: testOn, timeout: timeout, skip: skip, onPlatform: onPlatform);
 
 /// Creates a group of tests.
 ///
@@ -101,8 +121,28 @@ void test(String description, body(), {String testOn, Timeout timeout,
 /// If [skip] is a String or `true`, the group is skipped. If it's a String, it
 /// should explain why the group is skipped; this reason will be printed instead
 /// of running the group's tests.
+///
+/// [onPlatform] allows groups to be configured on a platform-by-platform
+/// basis. It's a map from strings that are parsed as [PlatformSelector]s to
+/// annotation classes: [Timeout], [Skip], or lists of those. These
+/// annotations apply only on the given platforms. For example:
+///
+///     group("potentially slow tests", () {
+///       // ...
+///     }, onPlatform: {
+///       // These tests are especially slow on Windows.
+///       "windows": new Timeout.factor(2),
+///       "browser": [
+///         new Skip("TODO: add browser support"),
+///         // They'll be slow on browsers once it works on them.
+///         new Timeout.factor(2)
+///       ]
+///     });
+///
+/// If multiple platforms match, the annotations apply in order as through
+/// they were in nested groups.
 void group(String description, void body(), {String testOn, Timeout timeout,
-        skip}) =>
+        skip, Map<String, dynamic> onPlatform}) =>
     _declarer.group(description, body,
         testOn: testOn, timeout: timeout, skip: skip);
 
diff --git a/test/backend/metadata_test.dart b/test/backend/metadata_test.dart
new file mode 100644
index 00000000..9e696e5e
--- /dev/null
+++ b/test/backend/metadata_test.dart
@@ -0,0 +1,66 @@
+// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/src/backend/metadata.dart';
+import 'package:test/src/backend/test_platform.dart';
+import 'package:test/src/frontend/timeout.dart';
+import 'package:test/src/frontend/skip.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group("onPlatform", () {
+    test("parses a valid map", () {
+      var metadata = new Metadata.parse(onPlatform: {
+        "chrome": new Timeout.factor(2),
+        "vm": [new Skip(), new Timeout.factor(3)]
+      });
+
+      var key = metadata.onPlatform.keys.first;
+      expect(key.evaluate(TestPlatform.chrome), isTrue);
+      expect(key.evaluate(TestPlatform.vm), isFalse);
+      var value = metadata.onPlatform.values.first;
+      expect(value.timeout.scaleFactor, equals(2));
+
+
+      key = metadata.onPlatform.keys.last;
+      expect(key.evaluate(TestPlatform.vm), isTrue);
+      expect(key.evaluate(TestPlatform.chrome), isFalse);
+      value = metadata.onPlatform.values.last;
+      expect(value.skip, isTrue);
+      expect(value.timeout.scaleFactor, equals(3));
+    });
+
+    test("refuses an invalid value", () {
+      expect(() {
+        new Metadata.parse(onPlatform: {"chrome": new TestOn("chrome")});
+      }, throwsArgumentError);
+    });
+
+    test("refuses an invalid value in a list", () {
+      expect(() {
+        new Metadata.parse(onPlatform: {"chrome": [new TestOn("chrome")]});
+      }, throwsArgumentError);
+    });
+
+    test("refuses an invalid platform selector", () {
+      expect(() {
+        new Metadata.parse(onPlatform: {"invalid": new Skip()});
+      }, throwsFormatException);
+    });
+
+    test("refuses multiple Timeouts", () {
+      expect(() {
+        new Metadata.parse(onPlatform: {
+          "chrome": [new Timeout.factor(2), new Timeout.factor(3)]
+        });
+      }, throwsArgumentError);
+    });
+
+    test("refuses multiple Skips", () {
+      expect(() {
+        new Metadata.parse(onPlatform: {"chrome": [new Skip(), new Skip()]});
+      }, throwsArgumentError);
+    });
+  });
+}
diff --git a/test/runner/browser/runner_test.dart b/test/runner/browser/runner_test.dart
index f2d3c638..146c05fd 100644
--- a/test/runner/browser/runner_test.dart
+++ b/test/runner/browser/runner_test.dart
@@ -513,6 +513,100 @@ void main() {
     expect(result.stdout, contains("Test timed out after 0 seconds."));
     expect(result.stdout, contains("-1: Some tests failed."));
   });
+
+  group("in onPlatform", () {
+    test("respects matching Skips", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("fail", () => throw 'oh no', onPlatform: {"chrome": new Skip()});
+}
+''');
+
+      var result = _runUnittest(["-p", "chrome", "test.dart"]);
+      expect(result.stdout, contains("+0 ~1: All tests skipped."));
+    });
+
+    test("ignores non-matching Skips", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {"vm": new Skip()});
+}
+''');
+
+      var result = _runUnittest(["-p", "chrome", "test.dart"]);
+      expect(result.stdout, contains("+1: All tests passed!"));
+    });
+
+    test("respects matching Timeouts", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("fail", () => throw 'oh no', onPlatform: {
+    "chrome": new Timeout(new Duration(seconds: 0))
+  });
+}
+''');
+
+      var result = _runUnittest(["-p", "chrome", "test.dart"]);
+      expect(result.stdout, contains("Test timed out after 0 seconds."));
+      expect(result.stdout, contains("-1: Some tests failed."));
+    });
+
+    test("ignores non-matching Timeouts", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {
+    "vm": new Timeout(new Duration(seconds: 0))
+  });
+}
+''');
+
+      var result = _runUnittest(["-p", "chrome", "test.dart"]);
+      expect(result.stdout, contains("+1: All tests passed!"));
+    });
+
+    test("applies matching platforms in order", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {
+    "chrome": new Skip("first"),
+    "chrome || windows": new Skip("second"),
+    "chrome || linux": new Skip("third"),
+    "chrome || mac-os": new Skip("fourth"),
+    "chrome || android": new Skip("fifth")
+  });
+}
+''');
+
+      var result = _runUnittest(["-p", "chrome", "test.dart"]);
+      expect(result.stdout, contains("Skip: fifth"));
+      expect(result.stdout, isNot(anyOf([
+        contains("Skip: first"),
+        contains("Skip: second"),
+        contains("Skip: third"),
+        contains("Skip: fourth")
+      ])));
+    });
+  });
 }
 
 ProcessResult _runUnittest(List<String> args) =>
diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart
index d717b671..0214ab9e 100644
--- a/test/runner/runner_test.dart
+++ b/test/runner/runner_test.dart
@@ -377,6 +377,100 @@ void main() {
     expect(result.stdout, contains("+0 ~1: All tests skipped."));
   });
 
+  group("in onPlatform", () {
+    test("respects matching Skips", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("fail", () => throw 'oh no', onPlatform: {"vm": new Skip()});
+}
+''');
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stdout, contains("+0 ~1: All tests skipped."));
+    });
+
+    test("ignores non-matching Skips", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {"chrome": new Skip()});
+}
+''');
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stdout, contains("+1: All tests passed!"));
+    });
+
+    test("respects matching Timeouts", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("fail", () => throw 'oh no', onPlatform: {
+    "vm": new Timeout(new Duration(seconds: 0))
+  });
+}
+''');
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stdout, contains("Test timed out after 0 seconds."));
+      expect(result.stdout, contains("-1: Some tests failed."));
+    });
+
+    test("ignores non-matching Timeouts", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {
+    "chrome": new Timeout(new Duration(seconds: 0))
+  });
+}
+''');
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stdout, contains("+1: All tests passed!"));
+    });
+
+    test("applies matching platforms in order", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync('''
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("success", () {}, onPlatform: {
+    "vm": new Skip("first"),
+    "vm || windows": new Skip("second"),
+    "vm || linux": new Skip("third"),
+    "vm || mac-os": new Skip("fourth"),
+    "vm || android": new Skip("fifth")
+  });
+}
+''');
+
+      var result = _runUnittest(["test.dart"]);
+      expect(result.stdout, contains("Skip: fifth"));
+      expect(result.stdout, isNot(anyOf([
+        contains("Skip: first"),
+        contains("Skip: second"),
+        contains("Skip: third"),
+        contains("Skip: fourth")
+      ])));
+    });
+  });
+
   group("flags:", () {
     test("with the --color flag, uses colors", () {
       new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_failure);
-- 
GitLab