Reland support flutter test on platform chrome (#33859)

diff --git a/packages/flutter_tools/build.yaml b/packages/flutter_tools/build.yaml
index 69fa907..c2d33c80 100644
--- a/packages/flutter_tools/build.yaml
+++ b/packages/flutter_tools/build.yaml
@@ -1,5 +1,8 @@
 targets:
   $default:
+    builders:
+      build_web_compilers|entrypoint:
+        enabled: false
     sources:
       exclude:
         - "test/data/**"
diff --git a/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart b/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart
index 913746b..0ff1a9c 100644
--- a/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart
+++ b/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart
@@ -12,17 +12,21 @@
 import 'package:build_runner_core/build_runner_core.dart' as core;
 import 'package:build_runner_core/src/generate/build_impl.dart';
 import 'package:build_runner_core/src/generate/options.dart';
+import 'package:build_test/builder.dart';
+import 'package:build_test/src/debug_test_builder.dart';
 import 'package:build_web_compilers/build_web_compilers.dart';
 import 'package:build_web_compilers/builders.dart';
 import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart';
 import 'package:logging/logging.dart';
 import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
+import 'package:test_core/backend.dart';
 import 'package:watcher/watcher.dart';
 
 import '../artifacts.dart';
 import '../base/file_system.dart';
 import '../base/logger.dart';
+import '../base/platform.dart';
 import '../compile.dart';
 import '../dart/package_map.dart';
 import '../globals.dart';
@@ -66,6 +70,20 @@
 /// The build application to compile a flutter application to the web.
 final List<core.BuilderApplication> builders = <core.BuilderApplication>[
   core.apply(
+    'flutter_tools|test_bootstrap',
+    <BuilderFactory>[
+      (BuilderOptions options) => const DebugTestBuilder(),
+      (BuilderOptions options) => const FlutterWebTestBootstrapBuilder(),
+    ],
+    core.toRoot(),
+    hideOutput: true,
+    defaultGenerateFor: const InputSet(
+      include: <String>[
+        'test/**',
+      ],
+    ),
+  ),
+  core.apply(
       'flutter_tools|module_library',
       <Builder Function(BuilderOptions)>[moduleLibraryBuilder],
       core.toAllPackages(),
@@ -109,7 +127,7 @@
     'flutter_tools|entrypoint',
     <BuilderFactory>[
       (BuilderOptions options) => FlutterWebEntrypointBuilder(
-          options.config['target'] ?? 'lib/main.dart'),
+          options.config['targets'] ?? <String>['lib/main.dart']),
     ],
     core.toRoot(),
     hideOutput: true,
@@ -117,6 +135,7 @@
       include: <String>[
         'lib/**',
         'web/**',
+        'test/**_test.dart.browser_test.dart',
       ],
     ),
   ),
@@ -135,13 +154,14 @@
   @override
   Future<void> initialize({
     @required Directory projectDirectory,
-    @required String target,
+    @required List<String> targets,
+    String testOutputDir,
   }) async {
     // Override the generated output directory so this does not conflict with
     // other build_runner output.
     core.overrideGeneratedOutputDirectory('flutter_web');
     _packageUriMapper = PackageUriMapper(
-        path.absolute(target), PackageMap.globalPackagesPath, null, null);
+        path.absolute('lib/main.dart'), PackageMap.globalPackagesPath, null, null);
     _packageGraph = core.PackageGraph.forPath(projectDirectory.path);
     final core.BuildEnvironment buildEnvironment = core.OverrideableEnvironment(
         core.IOEnvironment(_packageGraph), onLog: (LogRecord record) {
@@ -163,8 +183,18 @@
       trackPerformance: false,
       deleteFilesByDefault: true,
     );
+    final Set<core.BuildDirectory> buildDirs = <core.BuildDirectory>{
+      if (testOutputDir != null)
+        core.BuildDirectory(
+          'test',
+          outputLocation: core.OutputLocation(
+            testOutputDir,
+            useSymlinks: !platform.isWindows,
+          ),
+      ),
+    };
     final Status status =
-        logger.startProgress('Compiling $target for the Web...', timeout: null);
+        logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null);
     try {
       _builder = await BuildImpl.create(
         buildOptions,
@@ -172,12 +202,12 @@
         builders,
         <String, Map<String, dynamic>>{
           'flutter_tools|entrypoint': <String, dynamic>{
-            'target': target,
+            'targets': targets,
           }
         },
         isReleaseBuild: false,
       );
-      await _builder.run(const <AssetId, ChangeType>{});
+      await _builder.run(const <AssetId, ChangeType>{}, buildDirs: buildDirs);
     } finally {
       status.stop();
     }
@@ -205,9 +235,9 @@
 
 /// A ddc-only entrypoint builder that respects the Flutter target flag.
 class FlutterWebEntrypointBuilder implements Builder {
-  const FlutterWebEntrypointBuilder(this.target);
+  const FlutterWebEntrypointBuilder(this.targets);
 
-  final String target;
+  final List<String> targets;
 
   @override
   Map<String, List<String>> get buildExtensions => const <String, List<String>>{
@@ -222,10 +252,123 @@
 
   @override
   Future<void> build(BuildStep buildStep) async {
-    if (!buildStep.inputId.path.contains(target)) {
+    bool matches = false;
+    for (String target in targets) {
+      if (buildStep.inputId.path.contains(target)) {
+        matches = true;
+        break;
+      }
+    }
+    if (!matches) {
       return;
     }
     log.info('building for target ${buildStep.inputId.path}');
     await bootstrapDdc(buildStep, platform: flutterWebPlatform);
   }
 }
+
+class FlutterWebTestBootstrapBuilder implements Builder {
+  const FlutterWebTestBootstrapBuilder();
+
+  @override
+  Map<String, List<String>> get buildExtensions => const <String, List<String>>{
+    '_test.dart': <String>[
+      '_test.dart.browser_test.dart',
+    ]
+  };
+
+  @override
+  Future<void> build(BuildStep buildStep) async {
+    final AssetId id = buildStep.inputId;
+    final String contents = await buildStep.readAsString(id);
+    final String assetPath = id.pathSegments.first == 'lib'
+        ? path.url.join('packages', id.package, id.path)
+        : id.path;
+    final Metadata metadata = parseMetadata(
+        assetPath, contents, Runtime.builtIn.map((Runtime runtime) => runtime.name).toSet());
+
+    if (metadata.testOn.evaluate(SuitePlatform(Runtime.chrome))) {
+    await buildStep.writeAsString(id.addExtension('.browser_test.dart'), '''
+import 'dart:ui' as ui;
+import 'dart:html';
+import 'dart:js';
+
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test_api/src/backend/stack_trace_formatter.dart'; // ignore: implementation_imports
+import 'package:test_api/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
+import 'package:test_api/src/remote_listener.dart'; // ignore: implementation_imports
+import 'package:test_api/src/suite_channel_manager.dart'; // ignore: implementation_imports
+
+import "${path.url.basename(id.path)}" as test;
+
+Future<void> main() async {
+  // Extra initialization for flutter_web.
+  // The following parameters are hard-coded in Flutter's test embedder. Since
+  // we don't have an embedder yet this is the lowest-most layer we can put
+  // this stuff in.
+  await ui.webOnlyInitializeEngine();
+  internalBootstrapBrowserTest(() => test.main);
+}
+
+void internalBootstrapBrowserTest(Function getMain()) {
+  var channel =
+      serializeSuite(getMain, hidePrints: false, beforeLoad: () async {
+    var serialized =
+        await suiteChannel("test.browser.mapper").stream.first as Map;
+    if (serialized == null) return;
+  });
+  postMessageChannel().pipe(channel);
+}
+StreamChannel serializeSuite(Function getMain(),
+        {bool hidePrints = true, Future beforeLoad()}) =>
+    RemoteListener.start(getMain,
+        hidePrints: hidePrints, beforeLoad: beforeLoad);
+
+StreamChannel suiteChannel(String name) {
+  var manager = SuiteChannelManager.current;
+  if (manager == null) {
+    throw StateError('suiteChannel() may only be called within a test worker.');
+  }
+
+  return manager.connectOut(name);
+}
+
+StreamChannel postMessageChannel() {
+  var controller = StreamChannelController(sync: true);
+  window.onMessage.firstWhere((message) {
+    return message.origin == window.location.origin && message.data == "port";
+  }).then((message) {
+    var port = message.ports.first;
+    var portSubscription = port.onMessage.listen((message) {
+      controller.local.sink.add(message.data);
+    });
+
+    controller.local.stream.listen((data) {
+      port.postMessage({"data": data});
+    }, onDone: () {
+      port.postMessage({"event": "done"});
+      portSubscription.cancel();
+    });
+  });
+
+  context['parent'].callMethod('postMessage', [
+    JsObject.jsify({"href": window.location.href, "ready": true}),
+    window.location.origin,
+  ]);
+  return controller.foreign;
+}
+
+void setStackTraceMapper(StackTraceMapper mapper) {
+  var formatter = StackTraceFormatter.current;
+  if (formatter == null) {
+    throw StateError(
+        'setStackTraceMapper() may only be called within a test worker.');
+  }
+
+  formatter.configure(mapper: mapper);
+}
+''');
+    }
+  }
+}
+
diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart
index 6f1caa4..d30266d 100644
--- a/packages/flutter_tools/lib/src/commands/test.dart
+++ b/packages/flutter_tools/lib/src/commands/test.dart
@@ -99,6 +99,11 @@
         negatable: true,
         help: 'Whether to build the assets bundle for testing.\n'
               'Consider using --no-test-assets if assets are not required.',
+      )
+      ..addOption('platform',
+        allowed: const <String>['tester', 'chrome'],
+        defaultsTo: 'tester',
+        help: 'The platform to run the unit tests on. Defaults to "tester".'
       );
   }
 
@@ -166,6 +171,16 @@
             'Test files must be in that directory and end with the pattern "_test.dart".'
         );
       }
+    } else {
+      final List<String> fileCopy = <String>[];
+      for (String file in files) {
+        if (file.endsWith(platform.pathSeparator)) {
+          fileCopy.addAll(_findTests(fs.directory(file)));
+        } else {
+          fileCopy.add(file);
+        }
+      }
+      files = fileCopy;
     }
 
     CoverageCollector collector;
@@ -222,6 +237,7 @@
       concurrency: jobs,
       buildTestAssets: buildTestAssets,
       flutterProject: flutterProject,
+      web: argResults['platform'] == 'chrome',
     );
 
     if (collector != null) {
diff --git a/packages/flutter_tools/lib/src/resident_web_runner.dart b/packages/flutter_tools/lib/src/resident_web_runner.dart
index 4777b21..b9523b8 100644
--- a/packages/flutter_tools/lib/src/resident_web_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_web_runner.dart
@@ -120,7 +120,7 @@
     // Start the web compiler and build the assets.
     await webCompilationProxy.initialize(
       projectDirectory: currentProject.directory,
-      target: target,
+      targets: <String>[target],
     );
     _lastCompiled = DateTime.now();
     final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
diff --git a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
new file mode 100644
index 0000000..e487a9c
--- /dev/null
+++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
@@ -0,0 +1,681 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// ignore_for_file: implementation_imports
+
+import 'dart:async';
+
+import 'package:async/async.dart';
+import 'package:http_multi_server/http_multi_server.dart';
+import 'package:path/path.dart' as p; // ignore: package_path_import
+import 'package:pool/pool.dart';
+import 'package:shelf/shelf.dart' as shelf;
+import 'package:shelf/shelf_io.dart' as shelf_io;
+import 'package:shelf_packages_handler/shelf_packages_handler.dart';
+import 'package:shelf_static/shelf_static.dart';
+import 'package:shelf_web_socket/shelf_web_socket.dart';
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test_api/backend.dart';
+import 'package:test_api/src/backend/runtime.dart';
+import 'package:test_api/src/backend/suite_platform.dart';
+import 'package:test_api/src/util/stack_trace_mapper.dart';
+import 'package:test_core/src/runner/configuration.dart';
+import 'package:test_core/src/runner/environment.dart';
+import 'package:test_core/src/runner/platform.dart';
+import 'package:test_core/src/runner/plugin/platform_helpers.dart';
+import 'package:test_core/src/runner/runner_suite.dart';
+import 'package:test_core/src/runner/suite.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+import '../artifacts.dart';
+import '../base/common.dart';
+import '../base/file_system.dart';
+import '../cache.dart';
+import '../convert.dart';
+import '../dart/package_map.dart';
+import '../globals.dart';
+import '../web/chrome.dart';
+
+class FlutterWebPlatform extends PlatformPlugin {
+  FlutterWebPlatform._(this._server, this._config, this._root) {
+    // Look up the location of the testing resources.
+    final Map<String, Uri> packageMap = PackageMap(fs.path.join(
+      Cache.flutterRoot,
+      'packages',
+      'flutter_tools',
+      '.packages',
+    )).map;
+    testUri = packageMap['test'];
+    final shelf.Cascade cascade = shelf.Cascade()
+        .add(_webSocketHandler.handler)
+        .add(packagesDirHandler())
+        .add(_jsHandler.handler)
+        .add(createStaticHandler(
+          fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'),
+          serveFilesOutsidePath: true,
+        ))
+        .add(createStaticHandler(_config.suiteDefaults.precompiledPath,
+            serveFilesOutsidePath: true))
+        .add(_handleStaticArtifact)
+        .add(_wrapperHandler);
+    _server.mount(cascade.handler);
+  }
+
+  static Future<FlutterWebPlatform> start(String root) async {
+    final shelf_io.IOServer server =
+        shelf_io.IOServer(await HttpMultiServer.loopback(0));
+    return FlutterWebPlatform._(
+      server,
+      Configuration.current,
+      root,
+    );
+  }
+
+  Uri testUri;
+
+  /// The test runner configuration.
+  final Configuration _config;
+
+  /// The underlying server.
+  final shelf.Server _server;
+
+  /// The URL for this server.
+  Uri get url => _server.url;
+
+  /// The ahem text file.
+  File get ahem => fs.file(fs.path.join(
+        Cache.flutterRoot,
+        'packages',
+        'flutter_tools',
+        'static',
+        'Ahem.ttf',
+      ));
+
+  /// The require js binary.
+  File get requireJs => fs.file(fs.path.join(
+        artifacts.getArtifactPath(Artifact.engineDartSdkPath),
+        'lib',
+        'dev_compiler',
+        'amd',
+        'require.js',
+      ));
+
+  /// The ddc to dart stack trace mapper.
+  File get stackTraceMapper => fs.file(fs.path.join(
+        artifacts.getArtifactPath(Artifact.engineDartSdkPath),
+        'lib',
+        'dev_compiler',
+        'web',
+        'dart_stack_trace_mapper.js',
+      ));
+
+  /// The precompiled dart sdk.
+  File get dartSdk => fs.file(fs.path.join(
+        artifacts.getArtifactPath(Artifact.flutterWebSdk),
+        'kernel',
+        'amd',
+        'dart_sdk.js',
+      ));
+
+  /// The precompiled test javascript.
+  File get testDartJs => fs.file(fs.path.join(
+        testUri.toFilePath(),
+        'dart.js',
+      ));
+
+  File get testHostDartJs => fs.file(fs.path.join(
+        testUri.toFilePath(),
+        'src',
+        'runner',
+        'browser',
+        'static',
+        'host.dart.js',
+      ));
+
+  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
+    if (request.requestedUri.path.contains('require.js')) {
+      return shelf.Response.ok(
+        requireJs.openRead(),
+        headers: <String, String>{'Content-Type': 'text/javascript'},
+      );
+    } else if (request.requestedUri.path.contains('Ahem.ttf')) {
+      return shelf.Response.ok(ahem.openRead());
+    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
+      return shelf.Response.ok(
+        dartSdk.openRead(),
+        headers: <String, String>{'Content-Type': 'text/javascript'},
+      );
+    } else if (request.requestedUri.path
+        .contains('stack_trace_mapper.dart.js')) {
+      return shelf.Response.ok(
+        stackTraceMapper.openRead(),
+        headers: <String, String>{'Content-Type': 'text/javascript'},
+      );
+    } else if (request.requestedUri.path.contains('static/dart.js')) {
+      return shelf.Response.ok(
+        testDartJs.openRead(),
+        headers: <String, String>{'Content-Type': 'text/javascript'},
+      );
+    } else if (request.requestedUri.path.contains('host.dart.js')) {
+      return shelf.Response.ok(
+        testHostDartJs.openRead(),
+        headers: <String, String>{'Content-Type': 'text/javascript'},
+      );
+    } else {
+      return shelf.Response.notFound('Not Found');
+    }
+  }
+
+  final OneOffHandler _webSocketHandler = OneOffHandler();
+  final PathHandler _jsHandler = PathHandler();
+  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
+  final String _root;
+
+  bool get _closed => _closeMemo.hasRun;
+
+  // A map from browser identifiers to futures that will complete to the
+  // [BrowserManager]s for those browsers, or `null` if they failed to load.
+  final Map<Runtime, Future<BrowserManager>> _browserManagers =
+      <Runtime, Future<BrowserManager>>{};
+
+  // Mappers for Dartifying stack traces, indexed by test path.
+  final Map<String, StackTraceMapper> _mappers = <String, StackTraceMapper>{};
+
+  // A handler that serves wrapper files used to bootstrap tests.
+  shelf.Response _wrapperHandler(shelf.Request request) {
+    final String path = fs.path.fromUri(request.url);
+    if (path.endsWith('.html')) {
+      final String test = fs.path.withoutExtension(path) + '.dart';
+      final String scriptBase = htmlEscape.convert(fs.path.basename(test));
+      final String link = '<link rel="x-dart-test" href="$scriptBase">';
+      return shelf.Response.ok('''
+        <!DOCTYPE html>
+        <html>
+        <head>
+          <title>${htmlEscape.convert(test)} Test</title>
+          $link
+          <script src="static/dart.js"></script>
+        </head>
+        </html>
+      ''', headers: <String, String>{'Content-Type': 'text/html'});
+    }
+    printTrace('Did not find anything for request: ${request.url}');
+    return shelf.Response.notFound('Not found.');
+  }
+
+  @override
+  Future<RunnerSuite> load(String path, SuitePlatform platform,
+      SuiteConfiguration suiteConfig, Object message) async {
+    if (_closed) {
+      return null;
+    }
+    final Runtime browser = platform.runtime;
+    final BrowserManager browserManager = await _browserManagerFor(browser);
+    if (_closed || browserManager == null) {
+      return null;
+    }
+
+    final Uri suiteUrl = url.resolveUri(fs.path.toUri(fs.path.withoutExtension(
+            fs.path.relative(path, from: fs.path.join(_root, 'test'))) +
+        '.html'));
+    final RunnerSuite suite = await browserManager
+        .load(path, suiteUrl, suiteConfig, message, mapper: _mappers[path]);
+    if (_closed) {
+      return null;
+    }
+    return suite;
+  }
+
+  @override
+  StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) =>
+      throw UnimplementedError();
+
+  /// Returns the [BrowserManager] for [runtime], which should be a browser.
+  ///
+  /// If no browser manager is running yet, starts one.
+  Future<BrowserManager> _browserManagerFor(Runtime browser) {
+    final Future<BrowserManager> managerFuture = _browserManagers[browser];
+    if (managerFuture != null) {
+      return managerFuture;
+    }
+    final Completer<WebSocketChannel> completer =
+        Completer<WebSocketChannel>.sync();
+    final String path =
+        _webSocketHandler.create(webSocketHandler(completer.complete));
+    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
+    final Uri hostUrl = url
+        .resolve('static/index.html')
+        .replace(queryParameters: <String, String>{
+      'managerUrl': webSocketUrl.toString(),
+      'debug': _config.pauseAfterLoad.toString()
+    });
+
+    printTrace('Serving tests at $hostUrl');
+
+    final Future<BrowserManager> future = BrowserManager.start(
+      browser,
+      hostUrl,
+      completer.future,
+    );
+
+    // Store null values for browsers that error out so we know not to load them
+    // again.
+    _browserManagers[browser] = future.catchError((dynamic _) => null);
+
+    return future;
+  }
+
+  @override
+  Future<void> closeEphemeral() {
+    final List<Future<BrowserManager>> managers =
+        _browserManagers.values.toList();
+    _browserManagers.clear();
+    return Future.wait(managers.map((Future<BrowserManager> manager) async {
+      final BrowserManager result = await manager;
+      if (result == null) {
+        return;
+      }
+      await result.close();
+    }));
+  }
+
+  @override
+  Future<void> close() => _closeMemo.runOnce(() async {
+        final List<Future<dynamic>> futures = _browserManagers.values
+            .map<Future<dynamic>>((Future<BrowserManager> future) async {
+          final BrowserManager result = await future;
+          if (result == null) {
+            return;
+          }
+          await result.close();
+        }).toList();
+        futures.add(_server.close());
+        await Future.wait<void>(futures);
+      });
+}
+
+class OneOffHandler {
+  /// A map from URL paths to handlers.
+  final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{};
+
+  /// The counter of handlers that have been activated.
+  int _counter = 0;
+
+  /// The actual [shelf.Handler] that dispatches requests.
+  shelf.Handler get handler => _onRequest;
+
+  /// Creates a new one-off handler that forwards to [handler].
+  ///
+  /// Returns a string that's the URL path for hitting this handler, relative to
+  /// the URL for the one-off handler itself.
+  ///
+  /// [handler] will be unmounted as soon as it receives a request.
+  String create(shelf.Handler handler) {
+    final String path = _counter.toString();
+    _handlers[path] = handler;
+    _counter++;
+    return path;
+  }
+
+  /// Dispatches [request] to the appropriate handler.
+  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
+    final List<String> components = p.url.split(request.url.path);
+    if (components.isEmpty) {
+      return shelf.Response.notFound(null);
+    }
+    final String path = components.removeAt(0);
+    final FutureOr<shelf.Response> Function(shelf.Request) handler =
+        _handlers.remove(path);
+    if (handler == null) {
+      return shelf.Response.notFound(null);
+    }
+    return handler(request.change(path: path));
+  }
+}
+
+class PathHandler {
+  /// A trie of path components to handlers.
+  final _Node _paths = _Node();
+
+  /// The shelf handler.
+  shelf.Handler get handler => _onRequest;
+
+  /// Returns middleware that nests all requests beneath the URL prefix
+  /// [beneath].
+  static shelf.Middleware nestedIn(String beneath) {
+    return (FutureOr<shelf.Response> Function(shelf.Request) handler) {
+      final PathHandler pathHandler = PathHandler()..add(beneath, handler);
+      return pathHandler.handler;
+    };
+  }
+
+  /// Routes requests at or under [path] to [handler].
+  ///
+  /// If [path] is a parent or child directory of another path in this handler,
+  /// the longest matching prefix wins.
+  void add(String path, shelf.Handler handler) {
+    _Node node = _paths;
+    for (String component in p.url.split(path)) {
+      node = node.children.putIfAbsent(component, () => _Node());
+    }
+    node.handler = handler;
+  }
+
+  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
+    shelf.Handler handler;
+    int handlerIndex;
+    _Node node = _paths;
+    final List<String> components = p.url.split(request.url.path);
+    for (int i = 0; i < components.length; i++) {
+      node = node.children[components[i]];
+      if (node == null) {
+        break;
+      }
+      if (node.handler == null) {
+        continue;
+      }
+      handler = node.handler;
+      handlerIndex = i;
+    }
+
+    if (handler == null) {
+      return shelf.Response.notFound('Not found.');
+    }
+
+    return handler(
+        request.change(path: p.url.joinAll(components.take(handlerIndex + 1))));
+  }
+}
+
+/// A trie node.
+class _Node {
+  shelf.Handler handler;
+  final Map<String, _Node> children = <String, _Node>{};
+}
+
+class BrowserManager {
+  /// Creates a new BrowserManager that communicates with [browser] over
+  /// [webSocket].
+  BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) {
+    // The duration should be short enough that the debugging console is open as
+    // soon as the user is done setting breakpoints, but long enough that a test
+    // doing a lot of synchronous work doesn't trigger a false positive.
+    //
+    // Start this canceled because we don't want it to start ticking until we
+    // get some response from the iframe.
+    _timer = RestartableTimer(const Duration(seconds: 3), () {
+      for (RunnerSuiteController controller in _controllers) {
+        controller.setDebugging(true);
+      }
+    })
+      ..cancel();
+
+    // Whenever we get a message, no matter which child channel it's for, we the
+    // know browser is still running code which means the user isn't debugging.
+    _channel = MultiChannel<dynamic>(
+        webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
+      return stream.map((Object message) {
+        if (!_closed) {
+          _timer.reset();
+        }
+        for (RunnerSuiteController controller in _controllers) {
+          controller.setDebugging(false);
+        }
+
+        return message;
+      });
+    }));
+
+    _environment = _loadBrowserEnvironment();
+    _channel.stream.listen(_onMessage, onDone: close);
+  }
+
+  /// The browser instance that this is connected to via [_channel].
+  final Chrome _browser;
+
+  // TODO(nweiz): Consider removing the duplication between this and
+  // [_browser.name].
+  /// The [Runtime] for [_browser].
+  final Runtime _runtime;
+
+  /// The channel used to communicate with the browser.
+  ///
+  /// This is connected to a page running `static/host.dart`.
+  MultiChannel<dynamic> _channel;
+
+  /// A pool that ensures that limits the number of initial connections the
+  /// manager will wait for at once.
+  ///
+  /// This isn't the *total* number of connections; any number of iframes may be
+  /// loaded in the same browser. However, the browser can only load so many at
+  /// once, and we want a timeout in case they fail so we only wait for so many
+  /// at once.
+  final Pool _pool = Pool(8);
+
+  /// The ID of the next suite to be loaded.
+  ///
+  /// This is used to ensure that the suites can be referred to consistently
+  /// across the client and server.
+  int _suiteID = 0;
+
+  /// Whether the channel to the browser has closed.
+  bool _closed = false;
+
+  /// The completer for [_BrowserEnvironment.displayPause].
+  ///
+  /// This will be `null` as long as the browser isn't displaying a pause
+  /// screen.
+  CancelableCompleter<dynamic> _pauseCompleter;
+
+  /// The controller for [_BrowserEnvironment.onRestart].
+  final StreamController<dynamic> _onRestartController =
+      StreamController<dynamic>.broadcast();
+
+  /// The environment to attach to each suite.
+  Future<_BrowserEnvironment> _environment;
+
+  /// Controllers for every suite in this browser.
+  ///
+  /// These are used to mark suites as debugging or not based on the browser's
+  /// pings.
+  final Set<RunnerSuiteController> _controllers = <RunnerSuiteController>{};
+
+  // A timer that's reset whenever we receive a message from the browser.
+  //
+  // Because the browser stops running code when the user is actively debugging,
+  // this lets us detect whether they're debugging reasonably accurately.
+  RestartableTimer _timer;
+
+  final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>();
+
+  /// Starts the browser identified by [runtime] and has it connect to [url].
+  ///
+  /// [url] should serve a page that establishes a WebSocket connection with
+  /// this process. That connection, once established, should be emitted via
+  /// [future]. If [debug] is true, starts the browser in debug mode, with its
+  /// debugger interfaces on and detected.
+  ///
+  /// The [settings] indicate how to invoke this browser's executable.
+  ///
+  /// Returns the browser manager, or throws an [ApplicationException] if a
+  /// connection fails to be established.
+  static Future<BrowserManager> start(
+      Runtime runtime, Uri url, Future<WebSocketChannel> future,
+      {bool debug = false}) async {
+    final Chrome chrome =
+        await chromeLauncher.launch(url.toString(), headless: true);
+
+    final Completer<BrowserManager> completer = Completer<BrowserManager>();
+
+    unawaited(chrome.onExit.then((void _) {
+      throwToolExit('${runtime.name} exited before connecting.');
+    }).catchError((dynamic error, StackTrace stackTrace) {
+      if (completer.isCompleted) {
+        return;
+      }
+      completer.completeError(error, stackTrace);
+    }));
+    unawaited(future.then((WebSocketChannel webSocket) {
+      if (completer.isCompleted) {
+        return;
+      }
+      completer.complete(BrowserManager._(chrome, runtime, webSocket));
+    }).catchError((dynamic error, StackTrace stackTrace) {
+      chrome.close();
+      if (completer.isCompleted) {
+        return;
+      }
+      completer.completeError(error, stackTrace);
+    }));
+
+    return completer.future.timeout(const Duration(seconds: 30), onTimeout: () {
+      chrome.close();
+      throwToolExit('Timed out waiting for ${runtime.name} to connect.');
+      return;
+    });
+  }
+
+  /// Loads [_BrowserEnvironment].
+  Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
+    return _BrowserEnvironment(
+        this, null, _browser.remoteDebuggerUri, _onRestartController.stream);
+  }
+
+  /// Tells the browser the load a test suite from the URL [url].
+  ///
+  /// [url] should be an HTML page with a reference to the JS-compiled test
+  /// suite. [path] is the path of the original test suite file, which is used
+  /// for reporting. [suiteConfig] is the configuration for the test suite.
+  ///
+  /// If [mapper] is passed, it's used to map stack traces for errors coming
+  /// from this test suite.
+  Future<RunnerSuite> load(
+      String path, Uri url, SuiteConfiguration suiteConfig, Object message,
+      {StackTraceMapper mapper}) async {
+    url = url.replace(
+        fragment: Uri.encodeFull(jsonEncode(<String, Object>{
+      'metadata': suiteConfig.metadata.serialize(),
+      'browser': _runtime.identifier
+    })));
+
+    final int suiteID = _suiteID++;
+    RunnerSuiteController controller;
+    void closeIframe() {
+      if (_closed) {
+        return;
+      }
+      _controllers.remove(controller);
+      _channel.sink
+          .add(<String, Object>{'command': 'closeSuite', 'id': suiteID});
+    }
+
+    // The virtual channel will be closed when the suite is closed, in which
+    // case we should unload the iframe.
+    final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel();
+    final int suiteChannelID = virtualChannel.id;
+    final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
+        StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
+      closeIframe();
+      sink.close();
+    }));
+
+    return await _pool.withResource<RunnerSuite>(() async {
+      _channel.sink.add(<String, Object>{
+        'command': 'loadSuite',
+        'url': url.toString(),
+        'id': suiteID,
+        'channel': suiteChannelID
+      });
+
+      try {
+        controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
+            suiteConfig, await _environment, suiteChannel, message);
+        controller.channel('test.browser.mapper').sink.add(mapper?.serialize());
+
+        _controllers.add(controller);
+        return await controller.suite;
+      } catch (_) {
+        closeIframe();
+        rethrow;
+      }
+    });
+  }
+
+  /// An implementation of [Environment.displayPause].
+  CancelableOperation<dynamic> _displayPause() {
+    if (_pauseCompleter != null) {
+      return _pauseCompleter.operation;
+    }
+    _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
+      _channel.sink.add(<String, String>{'command': 'resume'});
+      _pauseCompleter = null;
+    });
+    _pauseCompleter.operation.value.whenComplete(() {
+      _pauseCompleter = null;
+    });
+    _channel.sink.add(<String, String>{'command': 'displayPause'});
+
+    return _pauseCompleter.operation;
+  }
+
+  /// The callback for handling messages received from the host page.
+  void _onMessage(dynamic message) {
+    switch (message['command'] as String) {
+      case 'ping':
+        break;
+      case 'restart':
+        _onRestartController.add(null);
+        break;
+      case 'resume':
+        if (_pauseCompleter != null) {
+          _pauseCompleter.complete();
+        }
+        break;
+      default:
+        // Unreachable.
+        assert(false);
+        break;
+    }
+  }
+
+  /// Closes the manager and releases any resources it owns, including closing
+  /// the browser.
+  Future<dynamic> close() {
+    return _closeMemoizer.runOnce(() {
+      _closed = true;
+      _timer.cancel();
+      if (_pauseCompleter != null) {
+        _pauseCompleter.complete();
+      }
+      _pauseCompleter = null;
+      _controllers.clear();
+      return _browser.close();
+    });
+  }
+}
+
+/// An implementation of [Environment] for the browser.
+///
+/// All methods forward directly to [BrowserManager].
+class _BrowserEnvironment implements Environment {
+  _BrowserEnvironment(this._manager, this.observatoryUrl,
+      this.remoteDebuggerUrl, this.onRestart);
+
+  final BrowserManager _manager;
+
+  @override
+  final bool supportsDebugging = true;
+
+  @override
+  final Uri observatoryUrl;
+
+  @override
+  final Uri remoteDebuggerUrl;
+
+  @override
+  final Stream<dynamic> onRestart;
+
+  @override
+  CancelableOperation<dynamic> displayPause() => _manager._displayPause();
+}
diff --git a/packages/flutter_tools/lib/src/test/runner.dart b/packages/flutter_tools/lib/src/test/runner.dart
index 2e1bc76..796a03d 100644
--- a/packages/flutter_tools/lib/src/test/runner.dart
+++ b/packages/flutter_tools/lib/src/test/runner.dart
@@ -5,7 +5,9 @@
 import 'dart:async';
 
 import 'package:meta/meta.dart';
+import 'package:test_api/backend.dart';
 import 'package:test_core/src/executable.dart' as test; // ignore: implementation_imports
+import 'package:test_core/src/runner/hack_register_platform.dart' as hack; // ignore: implementation_imports
 
 import '../artifacts.dart';
 import '../base/common.dart';
@@ -16,7 +18,9 @@
 import '../dart/package_map.dart';
 import '../globals.dart';
 import '../project.dart';
+import '../web/compile.dart';
 import 'flutter_platform.dart' as loader;
+import 'flutter_web_platform.dart';
 import 'watcher.dart';
 
 /// Runs tests using package:test and the Flutter engine.
@@ -40,6 +44,7 @@
   FlutterProject flutterProject,
   String icudtlPath,
   Directory coverageDirectory,
+  bool web = false,
 }) async {
   // Compute the command-line arguments for package:test.
   final List<String> testArgs = <String>[];
@@ -62,6 +67,32 @@
   for (String plainName in plainNames) {
     testArgs..add('--plain-name')..add(plainName);
   }
+  if (web) {
+    final String tempBuildDir = fs.systemTempDirectory
+      .createTempSync('_flutter_test')
+      .absolute
+      .uri
+      .toFilePath();
+    await webCompilationProxy.initialize(
+      projectDirectory: flutterProject.directory,
+      testOutputDir: tempBuildDir,
+      targets: testFiles.map((String testFile) {
+        return fs.path.relative(testFile, from: flutterProject.directory.path);
+      }).toList(),
+    );
+    testArgs.add('--platform=chrome');
+    testArgs.add('--precompiled=$tempBuildDir');
+    testArgs.add('--');
+    testArgs.addAll(testFiles);
+    hack.registerPlatformPlugin(
+      <Runtime>[Runtime.chrome],
+      () {
+        return FlutterWebPlatform.start(flutterProject.directory.path);
+      }
+    );
+    await test.main(testArgs);
+    return exitCode;
+  }
 
   testArgs.add('--');
   testArgs.addAll(testFiles);
diff --git a/packages/flutter_tools/lib/src/web/chrome.dart b/packages/flutter_tools/lib/src/web/chrome.dart
index 40be47f..3d91107 100644
--- a/packages/flutter_tools/lib/src/web/chrome.dart
+++ b/packages/flutter_tools/lib/src/web/chrome.dart
@@ -73,7 +73,10 @@
   static final Completer<Chrome> _currentCompleter = Completer<Chrome>();
 
   /// Launch the chrome browser to a particular `host` page.
-  Future<Chrome> launch(String url) async {
+  ///
+  /// `headless` defaults to false, and controls whether we open a headless or
+  /// a `headfull` browser.
+  Future<Chrome> launch(String url, { bool headless = false }) async {
     final String chromeExecutable = findChromeExecutable();
     final Directory dataDir = fs.systemTempDirectory.createTempSync();
     final int port = await os.findFreePort();
@@ -94,6 +97,8 @@
       '--no-default-browser-check',
       '--disable-default-apps',
       '--disable-translate',
+      if (headless)
+        ...<String>['--headless', '--disable-gpu'],
       url,
     ];
     final Process process = await processManager.start(args);
@@ -107,12 +112,14 @@
           throwToolExit('Unable to connect to Chrome DevTools.');
           return null;
         });
+    final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port'));
 
     return _connect(Chrome._(
       port,
       ChromeConnection('localhost', port),
       process: process,
       dataDir: dataDir,
+      remoteDebuggerUri: remoteDebuggerUri,
     ));
   }
 
@@ -138,15 +145,36 @@
       _connect(Chrome._(port, ChromeConnection('localhost', port)));
 
   static Future<Chrome> get connectedInstance => _currentCompleter.future;
+
+  /// Returns the full URL of the Chrome remote debugger for the main page.
+///
+/// This takes the [base] remote debugger URL (which points to a browser-wide
+/// page) and uses its JSON API to find the resolved URL for debugging the host
+/// page.
+Future<Uri> _getRemoteDebuggerUrl(Uri base) async {
+  try {
+    final HttpClient client = HttpClient();
+    final HttpClientRequest request = await client.getUrl(base.resolve('/json/list'));
+    final HttpClientResponse response = await request.close();
+    final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single;
+    return base.resolve(jsonObject.first['devtoolsFrontendUrl']);
+  } catch (_) {
+    // If we fail to talk to the remote debugger protocol, give up and return
+    // the raw URL rather than crashing.
+    return base;
+  }
+}
+
 }
 
 /// A class for managing an instance of Chrome.
 class Chrome {
-  const Chrome._(
+  Chrome._(
     this.debugPort,
     this.chromeConnection, {
     Process process,
     Directory dataDir,
+    this.remoteDebuggerUri,
   })  : _process = process,
         _dataDir = dataDir;
 
@@ -154,15 +182,18 @@
   final Process _process;
   final Directory _dataDir;
   final ChromeConnection chromeConnection;
+  final Uri remoteDebuggerUri;
 
   static Completer<Chrome> _currentCompleter = Completer<Chrome>();
 
+  Future<void> get onExit => _currentCompleter.future;
+
   Future<void> close() async {
     if (_currentCompleter.isCompleted) {
       _currentCompleter = Completer<Chrome>();
     }
     chromeConnection.close();
-    _process?.kill(ProcessSignal.SIGKILL);
+    _process?.kill();
     await _process?.exitCode;
     try {
       // Chrome starts another process as soon as it dies that modifies the
diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart
index e987498..732769a 100644
--- a/packages/flutter_tools/lib/src/web/compile.dart
+++ b/packages/flutter_tools/lib/src/web/compile.dart
@@ -91,7 +91,8 @@
   /// `projectDirectory`.
   Future<void> initialize({
     @required Directory projectDirectory,
-    @required String target,
+    @required List<String> targets,
+    String testOutputDir,
   }) async {
     throw UnimplementedError();
   }
diff --git a/packages/flutter_tools/static/index.html b/packages/flutter_tools/static/index.html
new file mode 100644
index 0000000..f69739d
--- /dev/null
+++ b/packages/flutter_tools/static/index.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>test Browser Host</title>
+</head>
+<body>
+  <svg id="dart" version="1.1" x="0px" y="0px" width="400px" height="400px" viewBox="0 0 400 400">
+    <path id="right-flank" fill="#0083C9" d="M249.379,226.486l-6.676,15.572L166.174,166h58.82c0,0,2.807-0.409,3.645,1.966L249.379,226.486z"/>
+    <path id="right-ear"   fill="#00D2B8" d="M201.84,141.906L166.174,166h58.82c0,0,2.168-0.25,2.645,0.566l-2.694-8.848l-15.024-14.68C207.555,140.329,203.578,140.744,201.84,141.906z"/>
+    <path id="left-flank"  fill="#00D2B8" d="M242.616,241.856l-15.022,6.799l-60.493-21.429c-1.035-0.395-1.101-3.696-1.101-3.696v-57.932L242.616,241.856z"/>
+    <path id="left-paw"    fill="#55DECA" d="M167.003,227.098l60.636,21.558l15.064-6.799L237.224,259h-43.856c0,0-14.077-13.929-18.141-17.993C171.162,236.943,169.162,233.989,167.003,227.098z"/>
+    <path id="right-paw"   fill="#00A4E4" d="M227.676,166.365c0.963,1.401,1.361,2.473,1.361,2.473l20.352,57.648l-6.711,15.37L259,236.463v-44.854c0,0-13.678-13.965-17.741-17.882C237.193,169.811,231.466,166.319,227.676,166.365z"/>
+    <path id="left-ear"    fill="#0083C9" d="M166.769,227.098c0,0-0.769-1.104-0.769-4.355v-57.144l-23.115,34.877c-1.626,1.774-1.567,6.538,1.595,9.755l13.636,13.892L166.769,227.098z"/>
+  </svg>
+  <div id="dark"></div>
+  <svg id="play" version="1.1" x="0px" y="0px" width="80px" height="80px" viewBox="0 0 25 25">
+    <defs><filter id="blur"><feGaussianBlur stdDeviation="0.3" id="feGaussianBlur5097" /></filter></defs>
+    <path d="M 3.777014,1.3715789 A 1.1838119,1.1838119 0 0 0 2.693923,2.5488509 V 22.444746 a 1.1838119,1.1838119 0 0 0 1.765908,1.035999 l 17.235259,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.459831,1.5128519 A 1.1838119,1.1838119 0 0 0 3.777014,1.3715789 z" style="opacity:0.5;stroke:#000000;stroke-width:1;filter:url(#blur)" />
+    <path style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.32722104" d="M 3.4770491,1.0714664 A 1.1838119,1.1838119 0 0 0 2.3939589,2.2487382 V 22.144633 a 1.1838119,1.1838119 0 0 0 1.7659079,1.035999 l 17.2352602,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.1598668,1.2127389 A 1.1838119,1.1838119 0 0 0 3.4770491,1.0714664 z" />
+  </svg>
+  <script src="host.dart.js"></script>
+</body>
+</html>