diff --git a/lib/src/runner/browser/chrome.dart b/lib/src/runner/browser/chrome.dart
new file mode 100644
index 0000000000000000000000000000000000000000..2a198a15fc669925a1603f4478b86ff1f4aa4c7c
--- /dev/null
+++ b/lib/src/runner/browser/chrome.dart
@@ -0,0 +1,88 @@
+// 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 unittest.runner.browser.chrome;
+
+import 'dart:async';
+import 'dart:io';
+
+import '../../util/io.dart';
+
+// TODO(nweiz): move this into its own package?
+// TODO(nweiz): support other browsers.
+/// A class for running an instance of Chrome.
+///
+/// Most of the communication with the browser is expected to happen via HTTP,
+/// so this exposes a bare-bones API. The browser starts as soon as the class is
+/// constructed, and is killed when [close] is called.
+///
+/// Any errors starting or running the process are reported through [onExit].
+class Chrome {
+  /// The underlying process.
+  Process _process;
+
+  /// The temporary directory used as the browser's user data dir.
+  ///
+  /// A new data dir is created for each run to ensure that they're
+  /// well-isolated.
+  String _dir;
+
+  /// A future that completes when the browser exits.
+  ///
+  /// If there's a problem starting or running the browser, this will complete
+  /// with an error.
+  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 Chrome open to the given [url], which may be a
+  /// [Uri] or a [String].
+  ///
+  /// If [executable] is passed, it's used as the Chrome executable. Otherwise
+  /// `"google-chrome"` will be looked up on the system PATH.
+  Chrome(url, {String executable}) {
+    if (executable == null) executable = "google-chrome";
+
+    // 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) {
+      _dir = dir;
+      return Process.start(executable, [
+        "--user-data-dir=$_dir",
+        url.toString(),
+        "--disable-extensions",
+        "--disable-popup-blocking",
+        "--bwsi",
+        "--no-first-run"
+      ]).then((process) {
+        _process = process;
+        _onProcessStartedCompleter.complete();
+
+        // TODO(nweiz): the browser's standard output is almost always useless
+        // noise, but we should allow the user to opt in to seeing it.
+        return _process.exitCode;
+      });
+    }).then((exitCode) {
+      if (exitCode != 0) throw "Chrome failed with exit code $exitCode.";
+    }).then(_onExitCompleter.complete)
+        .catchError(_onExitCompleter.completeError);
+  }
+
+  /// Kills the browser process.
+  ///
+  /// Returns the same [Future] as [onExit], except that it won't emit
+  /// exceptions.
+  Future close() {
+    _onProcessStarted.then((_) => _process.kill());
+
+    // Swallow exceptions. The user should explicitly use [onExit] for these.
+    return onExit.catchError((_) {});
+  }
+}
diff --git a/lib/src/util/io.dart b/lib/src/util/io.dart
index 181cb5c504b5e6c0280fb2a8b370d0e08d9141f9..733f1a9ecda3f62d10bda7ce6caf7bf7b75ae7db 100644
--- a/lib/src/util/io.dart
+++ b/lib/src/util/io.dart
@@ -57,3 +57,20 @@ Future withTempDir(Future fn(String path)) {
         .whenComplete(() => tempDir.deleteSync(recursive: true));
   });
 }
+
+/// Creates a URL string for [address]:[port].
+///
+/// Handles properly formatting IPv6 addresses.
+Uri baseUrlForAddress(InternetAddress address, int port) {
+  if (address.isLoopback) {
+    return new Uri(scheme: "http", host: "localhost", port: port);
+  }
+
+  // IPv6 addresses in URLs need to be enclosed in square brackets to avoid
+  // URL ambiguity with the ":" in the address.
+  if (address.type == InternetAddressType.IP_V6) {
+    return new Uri(scheme: "http", host: "[${address.address}]", port: port);
+  }
+
+  return new Uri(scheme: "http", host: address.address, port: port);
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 86cb82091812ddb8cbb977ac37dbe6e40d749a2e..7f2137b4f314c2e6fe04d98e65a3240c92e5c796 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -8,6 +8,8 @@ environment:
 dependencies:
   args: '^0.12.1'
   matcher: '^0.12.0-alpha.0'
+  shelf: '^0.5.3'
+  shelf_web_socket: '^0.0.1'
   stack_trace: '^1.2.0'
 dev_dependencies:
   fake_async: '^0.1.2'
diff --git a/test/browser/chrome_test.dart b/test/browser/chrome_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..9393d9194115ace58486af6e6603c8f73ac68a30
--- /dev/null
+++ b/test/browser/chrome_test.dart
@@ -0,0 +1,131 @@
+// 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 'dart:async';
+
+import 'package:unittest/unittest.dart';
+import 'package:unittest/src/runner/browser/chrome.dart';
+import 'package:unittest/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) {
+      if (request.url.path == "/") {
+        return new shelf.Response.ok("""
+<!doctype html>
+<html>
+<head>
+  <script src="index.js"></script>
+</head>
+</html>
+""", headers: {'content-type': 'text/html'});
+      } else if (request.url.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 Chrome with the given URL", () {
+      javaScript = '''
+var webSocket = new WebSocket(window.location.href.replace("http://", "ws://"));
+webSocket.addEventListener("open", function() {
+  webSocket.send("loaded!");
+});
+''';
+      var chrome = new Chrome(baseUrlForAddress(server.address, server.port));
+
+      return webSockets.first.then((webSocket) {
+        return webSocket.first.then(
+            (message) => expect(message, equals("loaded!")));
+      }).whenComplete(chrome.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 chrome = new Chrome(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 chrome and start a new one until we're sure that that has
+          // finished.
+          webSocket.first.then((_) {
+            chrome.close();
+
+            javaScript = '''
+var webSocket = new WebSocket(window.location.href.replace("http://", "ws://"));
+webSocket.addEventListener("open", function() {
+  webSocket.send(localStorage.getItem("data"));
+});
+''';
+            chrome = new Chrome(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(chrome.close),
+              completes);
+        }
+      }, count: 2));
+    });
+  });
+
+  test("a process can be killed synchronously after it's started", () {
+    return shelf_io.serve(expectAsync((_) {}, count: 0), 'localhost', 8080)
+        .then((server) {
+      var chrome = new Chrome(baseUrlForAddress(server.address, server.port));
+      return chrome.close().whenComplete(server.close);
+    });
+  });
+
+  test("reports an error in onExit", () {
+    var chrome = new Chrome("http://dart-lang.org",
+        executable: "_does_not_exist");
+    expect(chrome.onExit, throwsA(new isInstanceOf<ProcessException>()));
+  });
+}