diff --git a/.status b/.status
index 709e4f48362d06d17a8bc17688646da38d3f9d3a..c5d34c34c8403597990069c5c62b0e94f033e60d 100644
--- a/.status
+++ b/.status
@@ -23,6 +23,10 @@ lib/*/*: SkipByDesign
 lib/*/*/*: SkipByDesign
 lib/*/*/*/*: SkipByDesign
 
+# PhantomJS isn't supported on the bots (issue 23195).
+test/runner/browser/phantom_js: Skip
+test/runner/browser/runner_test: Skip
+
 test/runner/browser/loader_test: Pass, Slow
 
 # The test harness for browser tests doesn't play nicely with the new way of
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08a84739db34583e865134456172771ed0a02982..99ade249ffecf0325d0367d51b2f8fcc1c0ae411 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
 
 * Add support for running tests on Dartium and the Dartium content shell.
 
+* Add support for running tests on [PhantomJS](http://phantomjs.org/).
+
 ### 0.12.0-beta.6
 
 * Add the ability to run multiple test suites concurrently. By default a number
diff --git a/README.md b/README.md
index cb04c1ac6dc10b855a471e63b559307179672e01..9e951906fc2a12217247a0f42fa6496172ead231 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,9 @@ valid identifiers are:
 
 * `chrome`: Whether the test is running on Google Chrome.
 
+* `phantomjs`: Whether the test is running on
+  [PhantomJS](http://phantomjs.org/).
+
 * `dart-vm`: Whether the test is running on the Dart VM in any context,
   including Dartium. It's identical to `!js`.
 
diff --git a/lib/src/backend/test_platform.dart b/lib/src/backend/test_platform.dart
index 881dcf4aa27e52b176181671947f259e9799faa7..f2577d486f7b30921bbecd85cc6141e5859ca369 100644
--- a/lib/src/backend/test_platform.dart
+++ b/lib/src/backend/test_platform.dart
@@ -27,13 +27,18 @@ class TestPlatform {
   static const TestPlatform chrome = const TestPlatform._("Chrome", "chrome",
       isBrowser: true, isJS: true, isBlink: true);
 
+  /// PhantomJS.
+  static const TestPlatform phantomJS = const TestPlatform._(
+      "PhantomJS", "phantomjs",
+      isBrowser: true, isJS: true, isBlink: true);
+
   /// Mozilla Firefox.
   static const TestPlatform firefox = const TestPlatform._("Firefox", "firefox",
       isBrowser: true, isJS: true);
 
   /// A list of all instances of [TestPlatform].
   static const List<TestPlatform> all =
-      const [vm, dartium, contentShell, chrome, firefox];
+      const [vm, dartium, contentShell, chrome, phantomJS, firefox];
 
   /// Finds a platform by its identifier string.
   ///
diff --git a/lib/src/runner/browser/phantom_js.dart b/lib/src/runner/browser/phantom_js.dart
new file mode 100644
index 0000000000000000000000000000000000000000..b4e2f4a40e44ce8326e3be1f6f7a5ffb08d53e9d
--- /dev/null
+++ b/lib/src/runner/browser/phantom_js.dart
@@ -0,0 +1,87 @@
+// 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.
+
+library test.runner.browser.phantom_js;
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+
+import '../../util/io.dart';
+import 'browser.dart';
+
+/// The PhantomJS script that opens the host page.
+final _script = """
+var system = require('system');
+var page = require('webpage').create();
+
+// Pipe browser messages to the process's stdout. This isn't used by default,
+// but it can be useful for debugging.
+page.onConsoleMessage = function(message) {
+  console.log(message);
+}
+
+page.open(system.args[1], function(status) {
+  if (status !== "success") phantom.exit(1);
+});
+""";
+
+/// A class for running an instance of PhantomJS.
+///
+/// Any errors starting or running the process are reported through [onExit].
+class PhantomJS implements Browser {
+  /// The underlying process.
+  Process _process;
+
+  Future get onExit => _onExitCompleter.future;
+  final _onExitCompleter = new Completer();
+
+  /// A future that completes when the browser process has started.
+  ///
+  /// This is used to ensure that [close] works regardless of when it's called.
+  Future get _onProcessStarted => _onProcessStartedCompleter.future;
+  final _onProcessStartedCompleter = new Completer();
+
+  /// Starts a new instance of PhantomJS open to the given [url], which may be a
+  /// [Uri] or a [String].
+  ///
+  /// If [executable] is passed, it's used as the PhantomJS executable.
+  /// Otherwise the default executable name for the current OS will be used.
+  PhantomJS(url, {String executable}) {
+    if (executable == null) {
+      executable = Platform.isWindows ? "phantomjs.exe" : "phantomjs";
+    }
+
+    // Don't return a Future here because there's no need for the caller to wait
+    // for the process to actually start. They should just wait for the HTTP
+    // request instead.
+    withTempDir((dir) {
+      var script = p.join(dir, "script.js");
+      new File(script).writeAsStringSync(_script);
+
+      return Process.start(executable, [script, url.toString()])
+          .then((process) {
+        // PhantomJS synchronously emits standard output, which means that if we
+        // don't drain its stdout stream it can deadlock.
+        process.stdout.listen((_) {});
+
+        _process = process;
+        _onProcessStartedCompleter.complete();
+
+        return _process.exitCode;
+      });
+    }).then((exitCode) {
+      if (exitCode != 0) throw "PhantomJS failed with exit code $exitCode.";
+    }).then(_onExitCompleter.complete)
+        .catchError(_onExitCompleter.completeError);
+  }
+
+  Future close() {
+    _onProcessStarted.then((_) => _process.kill());
+
+    // Swallow exceptions. The user should explicitly use [onExit] for these.
+    return onExit.catchError((_) {});
+  }
+}
diff --git a/lib/src/runner/browser/server.dart b/lib/src/runner/browser/server.dart
index 67cead97be81560c4933e10d4fb0a827a70209e4..eacec516f7e273e5b1fbe3ea82429c6f393fdc8a 100644
--- a/lib/src/runner/browser/server.dart
+++ b/lib/src/runner/browser/server.dart
@@ -29,6 +29,7 @@ import 'chrome.dart';
 import 'dartium.dart';
 import 'content_shell.dart';
 import 'firefox.dart';
+import 'phantom_js.dart';
 
 /// A server that serves JS-compiled tests to browsers.
 ///
@@ -352,6 +353,7 @@ void main() {
       case TestPlatform.dartium: return new Dartium(url);
       case TestPlatform.contentShell: return new ContentShell(url);
       case TestPlatform.chrome: return new Chrome(url);
+      case TestPlatform.phantomJS: return new PhantomJS(url);
       case TestPlatform.firefox: return new Firefox(url);
       default:
         throw new ArgumentError("$browser is not a browser.");
diff --git a/test/runner/browser/phantom_js_test.dart b/test/runner/browser/phantom_js_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..09a5c6b60e93b0c20c16378c6ba93c66cbc00a3f
--- /dev/null
+++ b/test/runner/browser/phantom_js_test.dart
@@ -0,0 +1,144 @@
+// 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.
+
+@TestOn("vm")
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:test/test.dart';
+import 'package:test/src/runner/browser/phantom_js.dart';
+import 'package:test/src/util/io.dart';
+import 'package:shelf/shelf.dart' as shelf;
+import 'package:shelf/shelf_io.dart' as shelf_io;
+import 'package:shelf_web_socket/shelf_web_socket.dart';
+
+void main() {
+  group("running JavaScript", () {
+    // The JavaScript to serve in the server. We use actual JavaScript here to
+    // avoid the pain of compiling to JS in a test
+    var javaScript;
+
+    var servePage = (request) {
+      var path = request.url.path;
+
+      // We support both shelf 0.5.x and 0.6.x. The former has a leading "/"
+      // here, the latter does not.
+      if (path.startsWith("/")) path = path.substring(1);
+
+      if (path.isEmpty) {
+        return new shelf.Response.ok("""
+<!doctype html>
+<html>
+<head>
+  <script src="index.js"></script>
+</head>
+</html>
+""", headers: {'content-type': 'text/html'});
+      } else if (path == "index.js") {
+        return new shelf.Response.ok(javaScript,
+            headers: {'content-type': 'application/javascript'});
+      } else {
+        return new shelf.Response.notFound(null);
+      }
+    };
+
+    var server;
+    var webSockets;
+    setUp(() {
+      var webSocketsController = new StreamController();
+      webSockets = webSocketsController.stream;
+
+      return shelf_io.serve(
+          new shelf.Cascade()
+              .add(webSocketHandler(webSocketsController.add))
+              .add(servePage).handler,
+          'localhost', 0).then((server_) {
+        server = server_;
+      });
+    });
+
+    tearDown(() {
+      if (server != null) server.close();
+
+      javaScript = null;
+      server = null;
+      webSockets = null;
+    });
+
+    test("starts PhantomJs with the given URL", () {
+      javaScript = '''
+var webSocket = new WebSocket(window.location.href.replace("http://", "ws://"));
+webSocket.addEventListener("open", function() {
+  webSocket.send("loaded!");
+});
+''';
+      var phantomJS = new PhantomJS(
+          baseUrlForAddress(server.address, server.port));
+
+      return webSockets.first.then((webSocket) {
+        return webSocket.first.then(
+            (message) => expect(message, equals("loaded!")));
+      }).whenComplete(phantomJS.close);
+    });
+
+    test("doesn't preserve state across runs", () {
+      javaScript = '''
+localStorage.setItem("data", "value");
+
+var webSocket = new WebSocket(window.location.href.replace("http://", "ws://"));
+webSocket.addEventListener("open", function() {
+  webSocket.send("done");
+});
+''';
+      var phantomJS = new PhantomJS(
+          baseUrlForAddress(server.address, server.port));
+
+      var first = true;
+      webSockets.listen(expectAsync((webSocket) {
+        if (first) {
+          // The first request will set local storage data. We can't kill the
+          // old PhantomJS and start a new one until we're sure that that has
+          // finished.
+          webSocket.first.then((_) {
+            phantomJS.close();
+
+            javaScript = '''
+var webSocket = new WebSocket(window.location.href.replace("http://", "ws://"));
+webSocket.addEventListener("open", function() {
+  webSocket.send(localStorage.getItem("data"));
+});
+''';
+            phantomJS = new PhantomJS(
+                baseUrlForAddress(server.address, server.port));
+            first = false;
+          });
+        } else {
+          // The second request will return the local storage data. This should
+          // be null, indicating that no data was saved between runs.
+          expect(
+              webSocket.first
+                  .then((message) => expect(message, equals('null')))
+                  .whenComplete(phantomJS.close),
+              completes);
+        }
+      }, count: 2));
+    });
+  });
+
+  test("a process can be killed synchronously after it's started", () {
+    return shelf_io.serve(expectAsync((_) {}, count: 0), 'localhost', 0)
+        .then((server) {
+      var phantomJS = new PhantomJS(
+          baseUrlForAddress(server.address, server.port));
+      return phantomJS.close().whenComplete(server.close);
+    });
+  });
+
+  test("reports an error in onExit", () {
+    var phantomJS = new PhantomJS("http://dart-lang.org",
+        executable: "_does_not_exist");
+    expect(phantomJS.onExit, throwsA(new isInstanceOf<ProcessException>()));
+  });
+}
diff --git a/test/runner/browser/runner_test.dart b/test/runner/browser/runner_test.dart
index e2c4072f2aa8b0c3fb62ecb20291ca834e98fc2c..80f9774eefc82df5a18511c07a3ff6138c2514f3 100644
--- a/test/runner/browser/runner_test.dart
+++ b/test/runner/browser/runner_test.dart
@@ -126,6 +126,12 @@ void main() {
       expect(result.exitCode, equals(0));
     });
 
+    test("on PhantomJS", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_success);
+      var result = _runUnittest(["-p", "phantomjs", "test.dart"]);
+      expect(result.exitCode, equals(0));
+    });
+
     test("on Firefox", () {
       new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_success);
       var result = _runUnittest(["-p", "firefox", "test.dart"]);
@@ -175,6 +181,12 @@ void main() {
       expect(result.exitCode, equals(1));
     });
 
+    test("on PhantomJS", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_failure);
+      var result = _runUnittest(["-p", "phantomjs", "test.dart"]);
+      expect(result.exitCode, equals(1));
+    });
+
     test("on Firefox", () {
       new File(p.join(_sandbox, "test.dart")).writeAsStringSync(_failure);
       var result = _runUnittest(["-p", "firefox", "test.dart"]);
diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart
index 1da0be216cf45325c64589e6646fd90a392fce00..6b38a4d3be3e65d395d3d7fa4ad139d77e7e155d 100644
--- a/test/runner/runner_test.dart
+++ b/test/runner/runner_test.dart
@@ -47,7 +47,7 @@ Usage: pub run test:test [files or directories...]
 
 -N, --plain-name               A plain-text substring of the name of the test to run.
 -p, --platform                 The platform(s) on which to run the tests.
-                               [vm (default), dartium, content-shell, chrome, firefox]
+                               [vm (default), dartium, content-shell, chrome, phantomjs, firefox]
 
 -j, --concurrency=<threads>    The number of concurrent test suites run.
                                (defaults to $_defaultConcurrency)