Add a Node platform. (#663)

This also makes CompilerPool more general, so it can be used by both
the node and the browser platforms.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a15d73..eaf495e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.12.24
+
+* Add a `node` platform for compiling tests to JavaScript and running them on
+  Node.js.
+
 ## 0.12.23+1
 
 * Remove unused imports. 
diff --git a/README.md b/README.md
index 8cc47de..2519e20 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
   * [Restricting Tests to Certain Platforms](#restricting-tests-to-certain-platforms)
   * [Platform Selectors](#platform-selectors)
   * [Running Tests on Dartium](#running-tests-on-dartium)
+  * [Running Tests on Node.js](#running-tests-on-nodejs)
 * [Asynchronous Tests](#asynchronous-tests)
   * [Stream Matchers](#stream-matchers)
 * [Running Tests With Custom HTML](#running-tests-with-custom-html)
@@ -219,6 +220,8 @@
 
 * `ie`: Whether the test is running on Microsoft Internet Explorer.
 
+* `node`: Whether the test is running on Node.js.
+
 * `dart-vm`: Whether the test is running on the Dart VM in any context,
   including Dartium. It's identical to `!js`.
 
@@ -277,6 +280,24 @@
 
 [issue 63]: https://github.com/dart-lang/test/issues/63
 
+### Running Tests on Node.js
+
+The test runner also supports compiling tests to JavaScript and running them on
+[Node.js][] by passing `--platform node`. Note that Node has access to *neither*
+`dart:html` nor `dart:io`, so any platform-specific APIs will have to be invoked
+using [the `js` package][js]. However, it may be useful when testing APIs
+that are meant to be used by JavaScript code.
+
+[Node.js]: https://nodejs.org/en/
+[js]: https://pub.dartlang.org/packages/js
+
+The test runner looks for an executable named `node` (on Mac OS or Linux) or
+`node.exe` (on Windows) on your system path. When compiling Node.js tests, it
+passes `-Dnode=true`, so tests can determine whether they're running on Node
+using [`const bool.fromEnvironment("node")`][bool.fromEnvironment].
+
+[bool.fromEnvironment]: https://api.dartlang.org/stable/1.24.2/dart-core/bool/bool.fromEnvironment.html
+
 ## Asynchronous Tests
 
 Tests written with `async`/`await` will work automatically. The test runner
diff --git a/dart_test.yaml b/dart_test.yaml
index c73b23b..9af2457 100644
--- a/dart_test.yaml
+++ b/dart_test.yaml
@@ -31,3 +31,7 @@
   # Tests that run pub. These tests may need to be excluded when there are local
   # dependency_overrides.
   pub:
+
+  # Tests that use Node.js. These tests may need to be excluded on systems that
+  # don't have Node installed.
+  node:
diff --git a/lib/pub_serve.dart b/lib/pub_serve.dart
index 546aef5..1ae4fd5 100644
--- a/lib/pub_serve.dart
+++ b/lib/pub_serve.dart
@@ -22,6 +22,7 @@
     var id = transform.primaryId;
     transform.declareOutput(id.addExtension('.vm_test.dart'));
     transform.declareOutput(id.addExtension('.browser_test.dart'));
+    transform.declareOutput(id.addExtension('.node_test.dart'));
   }
 
   Future apply(Transform transform) async {
@@ -37,7 +38,7 @@
           import "${p.url.basename(id.path)}" as test;
 
           void main(_, SendPort message) {
-            internalBootstrapVmTest(test.main, message);
+            internalBootstrapVmTest(() => test.main, message);
           }
         '''));
 
@@ -49,7 +50,19 @@
           import "${p.url.basename(id.path)}" as test;
 
           void main() {
-            internalBootstrapBrowserTest(test.main);
+            internalBootstrapBrowserTest(() => test.main);
+          }
+        '''));
+
+    transform.addOutput(new Asset.fromString(
+        id.addExtension('.node_test.dart'),
+        '''
+          import "package:test/src/bootstrap/node.dart";
+
+          import "${p.url.basename(id.path)}" as test;
+
+          void main() {
+            internalBootstrapNodeTest(() => test.main);
           }
         '''));
 
diff --git a/lib/src/backend/test_platform.dart b/lib/src/backend/test_platform.dart
index dc21389..d387eba 100644
--- a/lib/src/backend/test_platform.dart
+++ b/lib/src/backend/test_platform.dart
@@ -45,6 +45,10 @@
       "Internet Explorer", "ie",
       isBrowser: true, isJS: true);
 
+  /// The command-line Node.js VM.
+  static const TestPlatform nodeJS =
+      const TestPlatform._("Node.js", "node", isJS: true);
+
   /// A list of all instances of [TestPlatform].
   static final UnmodifiableListView<TestPlatform> all =
       new UnmodifiableListView<TestPlatform>(_allPlatforms);
@@ -95,7 +99,8 @@
   TestPlatform.phantomJS,
   TestPlatform.firefox,
   TestPlatform.safari,
-  TestPlatform.internetExplorer
+  TestPlatform.internetExplorer,
+  TestPlatform.nodeJS
 ];
 
 /// **Do not call this function without express permission from the test package
diff --git a/lib/src/bootstrap/browser.dart b/lib/src/bootstrap/browser.dart
index c061711..cb79cc1 100644
--- a/lib/src/bootstrap/browser.dart
+++ b/lib/src/bootstrap/browser.dart
@@ -1,15 +1,15 @@
 // Copyright (c) 2017, 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 "../runner/browser/post_message_channel.dart";
 import "../runner/plugin/remote_platform_helpers.dart";
-import "../utils.dart";
 
 /// Bootstraps a browser test to communicate with the test runner.
 ///
 /// This should NOT be used directly, instead use the `test/pub_serve`
 /// transformer which will bootstrap your test and call this method.
-void internalBootstrapBrowserTest(AsyncFunction originalMain) {
-  var channel = serializeSuite(() => originalMain);
+void internalBootstrapBrowserTest(Function getMain()) {
+  var channel = serializeSuite(getMain, hidePrints: false);
   postMessageChannel().pipe(channel);
 }
diff --git a/lib/src/bootstrap/node.dart b/lib/src/bootstrap/node.dart
new file mode 100644
index 0000000..961377e
--- /dev/null
+++ b/lib/src/bootstrap/node.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2017, 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 "../runner/plugin/remote_platform_helpers.dart";
+import "../runner/node/stdio_channel.dart";
+
+/// Bootstraps a browser test to communicate with the test runner.
+///
+/// This should NOT be used directly, instead use the `test/pub_serve`
+/// transformer which will bootstrap your test and call this method.
+void internalBootstrapNodeTest(Function getMain()) {
+  var channel = serializeSuite(getMain);
+  stdioChannel().pipe(channel);
+}
diff --git a/lib/src/bootstrap/vm.dart b/lib/src/bootstrap/vm.dart
index c6cd010..3420e9b 100644
--- a/lib/src/bootstrap/vm.dart
+++ b/lib/src/bootstrap/vm.dart
@@ -1,22 +1,22 @@
 // Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
+
 import "dart:isolate";
 
 import "package:stream_channel/stream_channel.dart";
 
 import "../runner/plugin/remote_platform_helpers.dart";
 import "../runner/vm/catch_isolate_errors.dart";
-import "../utils.dart";
 
 /// Bootstraps a vm test to communicate with the test runner.
 ///
 /// This should NOT be used directly, instead use the `test/pub_serve`
 /// transformer which will bootstrap your test and call this method.
-void internalBootstrapVmTest(AsyncFunction originalMain, SendPort sendPort) {
+void internalBootstrapVmTest(Function getMain(), SendPort sendPort) {
   var channel = serializeSuite(() {
     catchIsolateErrors();
-    return originalMain;
+    return getMain();
   });
   new IsolateChannel.connectSend(sendPort).pipe(channel);
 }
diff --git a/lib/src/runner/browser/platform.dart b/lib/src/runner/browser/platform.dart
index 839211c..fe66c3c 100644
--- a/lib/src/runner/browser/platform.dart
+++ b/lib/src/runner/browser/platform.dart
@@ -25,13 +25,13 @@
 import '../../util/path_handler.dart';
 import '../../util/stack_trace_mapper.dart';
 import '../../utils.dart';
+import '../compiler_pool.dart';
 import '../configuration.dart';
 import '../configuration/suite.dart';
 import '../load_exception.dart';
 import '../plugin/platform.dart';
 import '../runner_suite.dart';
 import 'browser_manager.dart';
-import 'compiler_pool.dart';
 import 'polymer.dart';
 
 class BrowserPlatform extends PlatformPlugin {
@@ -70,9 +70,7 @@
   final _jsHandler = new PathHandler();
 
   /// The [CompilerPool] managing active instances of `dart2js`.
-  ///
-  /// This is `null` if tests are loaded from `pub serve`.
-  final CompilerPool _compilers;
+  final _compilers = new CompilerPool();
 
   /// The temporary directory in which compiled JS is emitted.
   final String _compiledDir;
@@ -120,8 +118,7 @@
       : _config = config,
         _root = root == null ? p.current : root,
         _compiledDir = config.pubServeUrl == null ? createTempDir() : null,
-        _http = config.pubServeUrl == null ? null : new HttpClient(),
-        _compilers = new CompilerPool() {
+        _http = config.pubServeUrl == null ? null : new HttpClient() {
     var cascade = new shelf.Cascade().add(_webSocketHandler.handler);
 
     if (_config.pubServeUrl == null) {
@@ -371,7 +368,18 @@
       var dir = new Directory(_compiledDir).createTempSync('test_').path;
       var jsPath = p.join(dir, p.basename(dartPath) + ".browser_test.dart.js");
 
-      await _compilers.compile(dartPath, jsPath, suiteConfig);
+      await _compilers.compile(
+          '''
+        import "package:test/src/bootstrap/browser.dart";
+
+        import "${p.toUri(p.absolute(dartPath))}" as test;
+
+        void main() {
+          internalBootstrapBrowserTest(() => test.main);
+        }
+      ''',
+          jsPath,
+          suiteConfig);
       if (_closed) return;
 
       var jsUrl = p.toUri(p.relative(dartPath, from: _root)).path +
diff --git a/lib/src/runner/browser/compiler_pool.dart b/lib/src/runner/compiler_pool.dart
similarity index 81%
rename from lib/src/runner/browser/compiler_pool.dart
rename to lib/src/runner/compiler_pool.dart
index 736da08..677362e 100644
--- a/lib/src/runner/browser/compiler_pool.dart
+++ b/lib/src/runner/compiler_pool.dart
@@ -11,10 +11,10 @@
 import 'package:path/path.dart' as p;
 import 'package:pool/pool.dart';
 
-import '../../util/io.dart';
-import '../configuration.dart';
-import '../configuration/suite.dart';
-import '../load_exception.dart';
+import '../util/io.dart';
+import 'configuration.dart';
+import 'configuration/suite.dart';
+import 'load_exception.dart';
 
 /// A regular expression matching the first status line printed by dart2js.
 final _dart2jsStatus =
@@ -39,35 +39,27 @@
   /// The memoizer for running [close] exactly once.
   final _closeMemo = new AsyncMemoizer();
 
-  /// Creates a compiler pool that multiple instances of `dart2js` at once.
-  CompilerPool() : _pool = new Pool(Configuration.current.concurrency);
+  /// Extra arguments to pass to dart2js.
+  final List<String> _extraArgs;
 
-  /// Compile the Dart code at [dartPath] to [jsPath].
+  /// Creates a compiler pool that multiple instances of `dart2js` at once.
+  CompilerPool([Iterable<String> extraArgs])
+      : _pool = new Pool(Configuration.current.concurrency),
+        _extraArgs = extraArgs?.toList() ?? const [];
+
+  /// Compiles [code] to [jsPath].
   ///
   /// This wraps the Dart code in the standard browser-testing wrapper.
   ///
   /// The returned [Future] will complete once the `dart2js` process completes
   /// *and* all its output has been printed to the command line.
-  Future compile(
-      String dartPath, String jsPath, SuiteConfiguration suiteConfig) {
+  Future compile(String code, String jsPath, SuiteConfiguration suiteConfig) {
     return _pool.withResource(() {
       if (_closed) return null;
 
       return withTempDir((dir) async {
         var wrapperPath = p.join(dir, "runInBrowser.dart");
-        new File(wrapperPath).writeAsStringSync('''
-          import "package:stream_channel/stream_channel.dart";
-
-          import "package:test/src/runner/plugin/remote_platform_helpers.dart";
-          import "package:test/src/runner/browser/post_message_channel.dart";
-
-          import "${p.toUri(p.absolute(dartPath))}" as test;
-
-          main(_) async {
-            var channel = serializeSuite(() => test.main, hidePrints: false);
-            postMessageChannel().pipe(channel);
-          }
-        ''');
+        new File(wrapperPath).writeAsStringSync(code);
 
         var dart2jsPath = _config.dart2jsPath;
         if (Platform.isWindows) dart2jsPath += '.bat';
@@ -77,7 +69,9 @@
           wrapperPath,
           "--out=$jsPath",
           await PackageResolver.current.processArgument
-        ]..addAll(suiteConfig.dart2jsArgs);
+        ]
+          ..addAll(_extraArgs)
+          ..addAll(suiteConfig.dart2jsArgs);
 
         if (_config.color) args.add("--enable-diagnostic-colors");
 
@@ -108,7 +102,7 @@
         var output = buffer.toString().replaceFirst(_dart2jsStatus, '');
         if (output.isNotEmpty) print(output);
 
-        if (exitCode != 0) throw new LoadException(dartPath, "dart2js failed.");
+        if (exitCode != 0) throw "dart2js failed.";
 
         _fixSourceMap(jsPath + '.map');
       });
diff --git a/lib/src/runner/loader.dart b/lib/src/runner/loader.dart
index 194b78a..dc654f6 100644
--- a/lib/src/runner/loader.dart
+++ b/lib/src/runner/loader.dart
@@ -19,6 +19,7 @@
 import 'configuration/suite.dart';
 import 'load_exception.dart';
 import 'load_suite.dart';
+import 'node/platform.dart';
 import 'parse_metadata.dart';
 import 'plugin/environment.dart';
 import 'plugin/hack_register_platform.dart';
@@ -49,6 +50,7 @@
   /// defaults to the working directory.
   Loader({String root}) {
     registerPlatformPlugin([TestPlatform.vm], () => new VMPlatform());
+    registerPlatformPlugin([TestPlatform.nodeJS], () => new NodePlatform());
     registerPlatformPlugin([
       TestPlatform.dartium,
       TestPlatform.contentShell,
diff --git a/lib/src/runner/node/platform.dart b/lib/src/runner/node/platform.dart
new file mode 100644
index 0000000..e48609a
--- /dev/null
+++ b/lib/src/runner/node/platform.dart
@@ -0,0 +1,194 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io';
+import 'dart:convert';
+
+import 'package:async/async.dart';
+import 'package:node_preamble/preamble.dart' as preamble;
+import 'package:package_resolver/package_resolver.dart';
+import 'package:path/path.dart' as p;
+import 'package:stream_channel/stream_channel.dart';
+
+import '../../backend/test_platform.dart';
+import '../../util/io.dart';
+import '../../util/stack_trace_mapper.dart';
+import '../../utils.dart';
+import '../compiler_pool.dart';
+import '../configuration.dart';
+import '../configuration/suite.dart';
+import '../load_exception.dart';
+import '../plugin/environment.dart';
+import '../plugin/platform.dart';
+import '../plugin/platform_helpers.dart';
+import '../runner_suite.dart';
+
+/// A platform that loads tests in Node.js processes.
+class NodePlatform extends PlatformPlugin {
+  /// The test runner configuration.
+  final Configuration _config;
+
+  /// The [CompilerPool] managing active instances of `dart2js`.
+  final _compilers = new CompilerPool(["-Dnode=true"]);
+
+  /// The temporary directory in which compiled JS is emitted.
+  final _compiledDir = createTempDir();
+
+  /// The HTTP client to use when fetching JS files for `pub serve`.
+  final HttpClient _http;
+
+  /// The Node executable to use.
+  String get _executable => Platform.isWindows ? "node.exe" : "node";
+
+  NodePlatform()
+      : _config = Configuration.current,
+        _http =
+            Configuration.current.pubServeUrl == null ? null : new HttpClient();
+
+  StreamChannel loadChannel(String path, TestPlatform platform) =>
+      throw new UnimplementedError();
+
+  Future<RunnerSuite> load(String path, TestPlatform platform,
+      SuiteConfiguration suiteConfig) async {
+    assert(platform == TestPlatform.nodeJS);
+
+    var pair = await _loadChannel(path, suiteConfig);
+    var controller = await deserializeSuite(
+        path, platform, suiteConfig, new PluginEnvironment(), pair.first,
+        mapper: pair.last);
+    return controller.suite;
+  }
+
+  /// Loads a [StreamChannel] communicating with the test suite at [path].
+  ///
+  /// Returns that channel along with a [StackTraceMapper] representing the
+  /// source map for the compiled suite.
+  Future<Pair<StreamChannel, StackTraceMapper>> _loadChannel(
+      String path, SuiteConfiguration suiteConfig) async {
+    var pair = await _spawnProcess(path, suiteConfig);
+    var process = pair.first;
+
+    // Node normally doesn't emit any standard error, but if it does we forward
+    // it to the print handler so it's associated with the load test.
+    process.stderr.transform(lineSplitter).listen(print);
+
+    var channel = new StreamChannel.withGuarantees(
+            process.stdout, process.stdin)
+        .transform(new StreamChannelTransformer.fromCodec(UTF8))
+        .transform(chunksToLines)
+        .transform(jsonDocument)
+        .transformStream(new StreamTransformer.fromHandlers(handleDone: (sink) {
+      if (process != null) process.kill();
+      sink.close();
+    }));
+
+    return new Pair(channel, pair.last);
+  }
+
+  /// Spawns a Node.js process that loads the Dart test suite at [path].
+  ///
+  /// Returns that channel along with a [StackTraceMapper] representing the
+  /// source map for the compiled suite.
+  Future<Pair<Process, StackTraceMapper>> _spawnProcess(
+      String path, SuiteConfiguration suiteConfig) async {
+    var dir = new Directory(_compiledDir).createTempSync('test_').path;
+    var jsPath = p.join(dir, p.basename(path) + ".node_test.dart.js");
+
+    if (_config.pubServeUrl == null) {
+      await _compilers.compile(
+          '''
+        import "package:test/src/bootstrap/node.dart";
+
+        import "${p.toUri(p.absolute(path))}" as test;
+
+        void main() {
+          internalBootstrapNodeTest(() => test.main);
+        }
+      ''',
+          jsPath,
+          suiteConfig);
+
+      // Add the Node.js preamble to ensure that the dart2js output is
+      // compatible. Use the minified version so the source map remains valid.
+      var jsFile = new File(jsPath);
+      await jsFile.writeAsString(
+          preamble.getPreamble(minified: true) + await jsFile.readAsString());
+
+      StackTraceMapper mapper;
+      if (!suiteConfig.jsTrace) {
+        var mapPath = jsPath + '.map';
+        mapper = new StackTraceMapper(await new File(mapPath).readAsString(),
+            mapUrl: p.toUri(mapPath),
+            packageResolver: await PackageResolver.current.asSync,
+            sdkRoot: p.toUri(sdkDir));
+      }
+
+      return new Pair(await Process.start(_executable, [jsPath]), mapper);
+    }
+
+    var url = _config.pubServeUrl.resolveUri(
+        p.toUri(p.relative(path, from: 'test') + '.node_test.dart.js'));
+
+    var js = await _get(url, path);
+    await new File(jsPath)
+        .writeAsString(preamble.getPreamble(minified: true) + js);
+
+    StackTraceMapper mapper;
+    if (!suiteConfig.jsTrace) {
+      var mapUrl = url.replace(path: url.path + '.map');
+      mapper = new StackTraceMapper(await _get(mapUrl, path),
+          mapUrl: mapUrl,
+          packageResolver: new SyncPackageResolver.root('packages'),
+          sdkRoot: p.toUri('packages/\$sdk'));
+    }
+
+    return new Pair(await Process.start(_executable, [jsPath]), mapper);
+  }
+
+  /// Runs an HTTP GET on [url].
+  ///
+  /// If this fails, throws a [LoadException] for [suitePath].
+  Future<String> _get(Uri url, String suitePath) async {
+    try {
+      var response = await (await _http.getUrl(url)).close();
+
+      if (response.statusCode != 200) {
+        // We don't care about the response body, but we have to drain it or
+        // else the process can't exit.
+        response.listen(null);
+
+        throw new LoadException(
+            suitePath,
+            "Error getting $url: ${response.statusCode} "
+            "${response.reasonPhrase}\n"
+            'Make sure "pub serve" is serving the test/ directory.');
+      }
+
+      return await UTF8.decodeStream(response);
+    } on IOException catch (error) {
+      var message = getErrorMessage(error);
+      if (error is SocketException) {
+        message = "${error.osError.message} "
+            "(errno ${error.osError.errorCode})";
+      }
+
+      throw new LoadException(
+          suitePath,
+          "Error getting $url: $message\n"
+          'Make sure "pub serve" is running.');
+    }
+  }
+
+  Future close() => _closeMemo.runOnce(() async {
+        await _compilers.close();
+
+        if (_config.pubServeUrl == null) {
+          new Directory(_compiledDir).deleteSync(recursive: true);
+        } else {
+          _http.close();
+        }
+      });
+  final _closeMemo = new AsyncMemoizer();
+}
diff --git a/lib/src/runner/node/stdio_channel.dart b/lib/src/runner/node/stdio_channel.dart
new file mode 100644
index 0000000..5f16f3a
--- /dev/null
+++ b/lib/src/runner/node/stdio_channel.dart
@@ -0,0 +1,47 @@
+// Copyright (c) 2017, 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.
+
+@JS()
+library _;
+
+import 'package:js/js.dart';
+import 'package:stream_channel/stream_channel.dart';
+
+import '../../utils.dart';
+
+@JS("require")
+external _Process _require(String module);
+
+@JS()
+class _Process {
+  external _Stdin get stdin;
+  external _Stdout get stdout;
+}
+
+@JS()
+class _Stdin {
+  external setEncoding(String encoding);
+  external on(String event, void callback(String chunk));
+}
+
+@JS()
+class _Stdout {
+  external setDefaultEncoding(String encoding);
+  external write(String chunk);
+}
+
+/// Returns a [StreamChannel] of JSON-encodable objects that communicates over
+/// the current process's stdout and stdin streams.
+StreamChannel stdioChannel() {
+  var controller = new StreamChannelController<String>(
+      allowForeignErrors: false, sync: true);
+  var process = _require("process");
+  process.stdin.setEncoding("utf8");
+  process.stdout.setDefaultEncoding("utf8");
+
+  controller.local.stream.listen((chunk) => process.stdout.write(chunk));
+  process.stdin.on("data", allowInterop(controller.local.sink.add));
+
+  return controller.foreign.transform(chunksToLines).transform(jsonDocument);
+}
diff --git a/lib/src/runner/plugin/remote_platform_helpers.dart b/lib/src/runner/plugin/remote_platform_helpers.dart
index 9920636..1fbd5c4 100644
--- a/lib/src/runner/plugin/remote_platform_helpers.dart
+++ b/lib/src/runner/plugin/remote_platform_helpers.dart
@@ -22,6 +22,5 @@
 /// suite will not be forwarded to the parent zone's print handler. However, the
 /// caller may want them to be forwarded in (for example) a browser context
 /// where they'll be visible in the development console.
-StreamChannel serializeSuite(AsyncFunction getMain(),
-        {bool hidePrints: true}) =>
+StreamChannel serializeSuite(Function getMain(), {bool hidePrints: true}) =>
     RemoteListener.start(getMain, hidePrints: hidePrints);
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 0d4d818..fd17f04 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -10,6 +10,7 @@
 import 'package:async/async.dart';
 import 'package:matcher/matcher.dart';
 import 'package:path/path.dart' as p;
+import 'package:stream_channel/stream_channel.dart';
 import 'package:term_glyph/term_glyph.dart' as glyph;
 
 import 'backend/invoker.dart';
@@ -33,6 +34,14 @@
         .transform(const LineSplitter())
         .listen(null, cancelOnError: cancelOnError));
 
+/// A [StreamChannelTransformer] that converts a chunked string channel to a
+/// line-by-line channel. Note that this is only safe for channels whose
+/// messages are guaranteed not to contain newlines.
+final chunksToLines = new StreamChannelTransformer(
+    const LineSplitter(),
+    new StreamSinkTransformer.fromHandlers(
+        handleData: (data, sink) => sink.add("$data\n")));
+
 /// A regular expression to match the exception prefix that some exceptions'
 /// [Object.toString] values contain.
 final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): ');
diff --git a/pubspec.yaml b/pubspec.yaml
index 93139c3..7fc6cec 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 0.12.24-dev
+version: 0.12.24
 author: Dart Team <misc@dartlang.org>
 description: A library for writing dart unit tests.
 homepage: https://github.com/dart-lang/test
@@ -14,7 +14,9 @@
   collection: '^1.8.0'
   glob: '^1.0.0'
   http_multi_server: '>=1.0.0 <3.0.0'
+  js: '^0.6.0'
   meta: '^1.0.0'
+  node_preamble: '^1.3.0'
   package_resolver: '^1.0.0'
   path: '^1.2.0'
   pool: '^1.3.0'
@@ -39,7 +41,6 @@
 dev_dependencies:
   fake_async: '^0.1.2'
   http: '^0.11.0'
-  js: '^0.6.0'
   shelf_test_handler: '^1.0.0'
   test_descriptor: '^1.0.0'
   test_process: '^1.0.0'
diff --git a/test/runner/node/runner_test.dart b/test/runner/node/runner_test.dart
new file mode 100644
index 0000000..d82ccde
--- /dev/null
+++ b/test/runner/node/runner_test.dart
@@ -0,0 +1,295 @@
+// Copyright (c) 2017, 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")
+@Tags(const ["node"])
+
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import 'package:test/test.dart';
+
+import '../../io.dart';
+
+final _success = """
+  import 'package:test/test.dart';
+
+  void main() {
+    test("success", () {});
+  }
+""";
+
+final _failure = """
+  import 'package:test/test.dart';
+
+  void main() {
+    test("failure", () => throw new TestFailure("oh no"));
+  }
+""";
+
+void main() {
+  group("fails gracefully if", () {
+    test("a test file fails to compile", () async {
+      await d.file("test.dart", "invalid Dart file").create();
+      var test = await runTest(["-p", "node", "test.dart"]);
+
+      expect(
+          test.stdout,
+          containsInOrder([
+            "Expected a declaration, but got 'invalid'",
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": dart2js failed.'
+          ]));
+      await test.shouldExit(1);
+    });
+
+    test("a test file throws", () async {
+      await d.file("test.dart", "void main() => throw 'oh no';").create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": oh no'
+          ]));
+      await test.shouldExit(1);
+    });
+
+    test("a test file doesn't have a main defined", () async {
+      await d.file("test.dart", "void foo() {}").create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": No top-level main() function defined.'
+          ]));
+      await test.shouldExit(1);
+    });
+
+    test("a test file has a non-function main", () async {
+      await d.file("test.dart", "int main;").create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": Top-level main getter is not a function.'
+          ]));
+      await test.shouldExit(1);
+    });
+
+    test("a test file has a main with arguments", () async {
+      await d.file("test.dart", "void main(arg) {}").create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": Top-level main() function takes arguments.'
+          ]));
+      await test.shouldExit(1);
+    });
+  });
+
+  group("runs successful tests", () {
+    test("on Node and the VM", () async {
+      await d.file("test.dart", _success).create();
+      var test = await runTest(["-p", "node", "-p", "vm", "test.dart"]);
+
+      expect(test.stdout, emitsThrough(contains("+2: All tests passed!")));
+      await test.shouldExit(0);
+    });
+
+    // Regression test; this broke in 0.12.0-beta.9.
+    test("on a file in a subdirectory", () async {
+      await d.dir("dir", [d.file("test.dart", _success)]).create();
+
+      var test = await runTest(["-p", "node", "dir/test.dart"]);
+      expect(test.stdout, emitsThrough(contains("+1: All tests passed!")));
+      await test.shouldExit(0);
+    });
+  });
+
+  test("defines a node environment constant", () async {
+    await d
+        .file(
+            "test.dart",
+            """
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () {
+            expect(const bool.fromEnvironment("node"), isTrue);
+          });
+        }
+      """)
+        .create();
+
+    var test = await runTest(["-p", "node", "test.dart"]);
+    expect(test.stdout, emitsThrough(contains("+1: All tests passed!")));
+    await test.shouldExit(0);
+  });
+
+  test("runs failing tests that fail only on node", () async {
+    await d
+        .file(
+            "test.dart",
+            """
+        import 'package:path/path.dart' as p;
+        import 'package:test/test.dart';
+
+        void main() {
+          test("test", () {
+            if (const bool.fromEnvironment("node")) {
+              throw new TestFailure("oh no");
+            }
+          });
+        }
+      """)
+        .create();
+
+    var test = await runTest(["-p", "node", "-p", "vm", "test.dart"]);
+    expect(test.stdout, emitsThrough(contains("+1 -1: Some tests failed.")));
+    await test.shouldExit(1);
+  });
+
+  test("forwards prints from the Node test", () async {
+    await d
+        .file(
+            "test.dart",
+            """
+      import 'dart:async';
+
+      import 'package:test/test.dart';
+
+      void main() {
+        test("test", () {
+          print("Hello,");
+          return new Future(() => print("world!"));
+        });
+      }
+    """)
+        .create();
+
+    var test = await runTest(["-p", "node", "test.dart"]);
+    expect(test.stdout, emitsInOrder([emitsThrough("Hello,"), "world!"]));
+    await test.shouldExit(0);
+  });
+
+  test("dartifies stack traces for JS-compiled tests by default", () async {
+    await d.file("test.dart", _failure).create();
+
+    var test = await runTest(["-p", "node", "--verbose-trace", "test.dart"]);
+    expect(
+        test.stdout,
+        containsInOrder(
+            [" main.<fn>", "package:test", "dart:async/zone.dart"]));
+    await test.shouldExit(1);
+  });
+
+  test("doesn't dartify stack traces for JS-compiled tests with --js-trace",
+      () async {
+    await d.file("test.dart", _failure).create();
+
+    var test = await runTest(
+        ["-p", "node", "--verbose-trace", "--js-trace", "test.dart"]);
+    expect(test.stdoutStream(), neverEmits(endsWith(" main.<fn>")));
+    expect(test.stdoutStream(), neverEmits(contains("package:test")));
+    expect(test.stdoutStream(), neverEmits(contains("dart:async/zone.dart")));
+    expect(test.stdout, emitsThrough(contains("-1: Some tests failed.")));
+    await test.shouldExit(1);
+  });
+
+  group("with onPlatform", () {
+    test("respects matching Skips", () async {
+      await d
+          .file(
+              "test.dart",
+              '''
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("fail", () => throw 'oh no', onPlatform: {"node": new Skip()});
+        }
+      ''')
+          .create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(test.stdout, emitsThrough(contains("+0 ~1: All tests skipped.")));
+      await test.shouldExit(0);
+    });
+
+    test("ignores non-matching Skips", () async {
+      await d
+          .file(
+              "test.dart",
+              '''
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("success", () {}, onPlatform: {"browser": new Skip()});
+        }
+      ''')
+          .create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(test.stdout, emitsThrough(contains("+1: All tests passed!")));
+      await test.shouldExit(0);
+    });
+  });
+
+  group("with an @OnPlatform annotation", () {
+    test("respects matching Skips", () async {
+      await d
+          .file(
+              "test.dart",
+              '''
+        @OnPlatform(const {"js": const Skip()})
+
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("fail", () => throw 'oh no');
+        }
+      ''')
+          .create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(test.stdout, emitsThrough(contains("~1: All tests skipped.")));
+      await test.shouldExit(0);
+    });
+
+    test("ignores non-matching Skips", () async {
+      await d
+          .file(
+              "test.dart",
+              '''
+        @OnPlatform(const {"vm": const Skip()})
+
+        import 'dart:async';
+
+        import 'package:test/test.dart';
+
+        void main() {
+          test("success", () {});
+        }
+      ''')
+          .create();
+
+      var test = await runTest(["-p", "node", "test.dart"]);
+      expect(test.stdout, emitsThrough(contains("+1: All tests passed!")));
+      await test.shouldExit(0);
+    });
+  });
+}
diff --git a/test/runner/pub_serve_test.dart b/test/runner/pub_serve_test.dart
index e79af54..1c1c3de 100644
--- a/test/runner/pub_serve_test.dart
+++ b/test/runner/pub_serve_test.dart
@@ -106,6 +106,14 @@
       await pub.kill();
     }, tags: 'chrome');
 
+    test("runs those tests on Node", () async {
+      var pub = await runPubServe();
+      var test = await runTest([_pubServeArg, '-p', 'node']);
+      expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
+      await test.shouldExit(0);
+      await pub.kill();
+    }, tags: 'node');
+
     test("runs those tests on content shell", () async {
       var pub = await runPubServe();
       var test = await runTest([_pubServeArg, '-p', 'content-shell']);
@@ -174,6 +182,26 @@
       }, tags: 'content-shell');
     });
 
+    test(
+        "gracefully handles pub serve running on the wrong directory for Node "
+        "tests", () async {
+      await d.dir("web").create();
+
+      var pub = await runPubServe(args: ['web']);
+      var test = await runTest([_pubServeArg, '-p', 'node']);
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling ${p.join("test", "my_test.dart")} [E]',
+            'Failed to load "${p.join("test", "my_test.dart")}":',
+            '404 Not Found',
+            'Make sure "pub serve" is serving the test/ directory.'
+          ]));
+      await test.shouldExit(1);
+
+      await pub.kill();
+    }, tags: 'node');
+
     test("gracefully handles unconfigured transformers", () async {
       await d
           .file(
@@ -269,32 +297,64 @@
           .create();
     });
 
-    test("dartifies stack traces for JS-compiled tests by default", () async {
-      var pub = await runPubServe();
-      var test =
-          await runTest([_pubServeArg, '-p', 'chrome', '--verbose-trace']);
-      expect(
-          test.stdout,
-          containsInOrder(
-              [" main.<fn>", "package:test", "dart:async/zone.dart"]));
-      await test.shouldExit(1);
-      pub.kill();
-    }, tags: 'chrome');
+    group("dartifies stack traces for JS-compiled tests by default", () {
+      test("on a browser", () async {
+        var pub = await runPubServe();
+        var test =
+            await runTest([_pubServeArg, '-p', 'chrome', '--verbose-trace']);
+        expect(
+            test.stdout,
+            containsInOrder(
+                [" main.<fn>", "package:test", "dart:async/zone.dart"]));
+        await test.shouldExit(1);
+        pub.kill();
+      }, tags: 'chrome');
 
-    test("doesn't dartify stack traces for JS-compiled tests with --js-trace",
-        () async {
-      var pub = await runPubServe();
-      var test = await runTest(
-          [_pubServeArg, '-p', 'chrome', '--js-trace', '--verbose-trace']);
+      test("on Node", () async {
+        var pub = await runPubServe();
+        var test =
+            await runTest([_pubServeArg, '-p', 'node', '--verbose-trace']);
+        expect(
+            test.stdout,
+            containsInOrder(
+                [" main.<fn>", "package:test", "dart:async/zone.dart"]));
+        await test.shouldExit(1);
+        pub.kill();
+      }, tags: 'node');
+    });
 
-      expect(test.stdoutStream(), neverEmits(endsWith(" main.<fn>")));
-      expect(test.stdoutStream(), neverEmits(contains("package:test")));
-      expect(test.stdoutStream(), neverEmits(contains("dart:async/zone.dart")));
-      expect(test.stdout, emitsThrough(contains("-1: Some tests failed.")));
-      await test.shouldExit(1);
+    group("doesn't dartify stack traces for JS-compiled tests with --js-trace",
+        () {
+      test("on a browser", () async {
+        var pub = await runPubServe();
+        var test = await runTest(
+            [_pubServeArg, '-p', 'chrome', '--js-trace', '--verbose-trace']);
 
-      await pub.kill();
-    }, tags: 'chrome');
+        expect(test.stdoutStream(), neverEmits(endsWith(" main.<fn>")));
+        expect(test.stdoutStream(), neverEmits(contains("package:test")));
+        expect(
+            test.stdoutStream(), neverEmits(contains("dart:async/zone.dart")));
+        expect(test.stdout, emitsThrough(contains("-1: Some tests failed.")));
+        await test.shouldExit(1);
+
+        await pub.kill();
+      }, tags: 'chrome');
+
+      test("on Node", () async {
+        var pub = await runPubServe();
+        var test = await runTest(
+            [_pubServeArg, '-p', 'node', '--js-trace', '--verbose-trace']);
+
+        expect(test.stdoutStream(), neverEmits(endsWith(" main.<fn>")));
+        expect(test.stdoutStream(), neverEmits(contains("package:test")));
+        expect(
+            test.stdoutStream(), neverEmits(contains("dart:async/zone.dart")));
+        expect(test.stdout, emitsThrough(contains("-1: Some tests failed.")));
+        await test.shouldExit(1);
+
+        await pub.kill();
+      }, tags: 'node');
+    });
   });
 
   test("gracefully handles pub serve not running for VM tests", () async {
@@ -329,6 +389,24 @@
     await test.shouldExit(1);
   }, tags: 'chrome');
 
+  test("gracefully handles pub serve not running for Node tests", () async {
+    var test = await runTest(['--pub-serve=54321', '-p', 'node']);
+    var message = Platform.isWindows
+        ? 'The remote computer refused the network connection.'
+        : 'Connection refused (errno ';
+
+    expect(
+        test.stdout,
+        containsInOrder([
+          '-1: compiling ${p.join("test", "my_test.dart")} [E]',
+          'Failed to load "${p.join("test", "my_test.dart")}":',
+          'Error getting http://localhost:54321/my_test.dart.node_test.dart.js:'
+              ' $message',
+          'Make sure "pub serve" is running.'
+        ]));
+    await test.shouldExit(1);
+  }, tags: 'node');
+
   test("gracefully handles a test file not being in test/", () async {
     new File(p.join(d.sandbox, 'test/my_test.dart'))
         .copySync(p.join(d.sandbox, 'my_test.dart'));
diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart
index 085b08e..ed69cb4 100644
--- a/test/runner/runner_test.dart
+++ b/test/runner/runner_test.dart
@@ -54,7 +54,7 @@
     "[vm (default), dartium, content-shell, chrome, phantomjs, firefox" +
         (Platform.isMacOS ? ", safari" : "") +
         (Platform.isWindows ? ", ie" : "") +
-        "]";
+        ", node]";
 
 final _usage = """
 Usage: pub run test [files or directories...]