Add chrome launching code to browser_launcher (dart-lang/browser_launcher#4)

* Add chrome launching code to browser_launcher
diff --git a/pkgs/browser_launcher/.travis.yml b/pkgs/browser_launcher/.travis.yml
index c936b6f..b4e1991 100644
--- a/pkgs/browser_launcher/.travis.yml
+++ b/pkgs/browser_launcher/.travis.yml
@@ -5,7 +5,7 @@
   - dev
 
 dart_task:
-  # - test
+  - test
   - dartanalyzer: --fatal-infos --fatal-warnings .
 
 matrix:
diff --git a/pkgs/browser_launcher/lib/src/chrome.dart b/pkgs/browser_launcher/lib/src/chrome.dart
index 79bf402..7f1250b 100644
--- a/pkgs/browser_launcher/lib/src/chrome.dart
+++ b/pkgs/browser_launcher/lib/src/chrome.dart
@@ -1,3 +1,186 @@
 // Copyright (c) 2019, 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:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
+
+const _chromeEnvironment = 'CHROME_EXECUTABLE';
+const _linuxExecutable = 'google-chrome';
+const _macOSExecutable =
+    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const _windowsExecutable = r'Google\Chrome\Application\chrome.exe';
+
+String get _executable {
+  if (Platform.environment.containsKey(_chromeEnvironment)) {
+    return Platform.environment[_chromeEnvironment];
+  }
+  if (Platform.isLinux) return _linuxExecutable;
+  if (Platform.isMacOS) return _macOSExecutable;
+  if (Platform.isWindows) {
+    final windowsPrefixes = [
+      Platform.environment['LOCALAPPDATA'],
+      Platform.environment['PROGRAMFILES'],
+      Platform.environment['PROGRAMFILES(X86)']
+    ];
+    return p.join(
+      windowsPrefixes.firstWhere((prefix) {
+        if (prefix == null) return false;
+        final path = p.join(prefix, _windowsExecutable);
+        return File(path).existsSync();
+      }, orElse: () => '.'),
+      _windowsExecutable,
+    );
+  }
+  throw StateError('Unexpected platform type.');
+}
+
+/// Manager for an instance of Chrome.
+class Chrome {
+  Chrome._(
+    this.debugPort,
+    this.chromeConnection, {
+    Process process,
+    Directory dataDir,
+  })  : _process = process,
+        _dataDir = dataDir;
+
+  final int debugPort;
+  final ChromeConnection chromeConnection;
+  final Process _process;
+  final Directory _dataDir;
+
+  /// Connects to an instance of Chrome with an open debug port.
+  static Future<Chrome> fromExisting(int port) async =>
+      _connect(Chrome._(port, ChromeConnection('localhost', port)));
+
+  /// Starts Chrome with the given arguments and a specific port.
+  ///
+  /// Only one instance of Chrome can run at a time. Each url in [urls] will be
+  /// loaded in a separate tab.
+  static Future<Chrome> startWithDebugPort(
+    List<String> urls, {
+    int debugPort,
+    bool headless = false,
+  }) async {
+    final dataDir = Directory.systemTemp.createTempSync();
+    final port = debugPort == null || debugPort == 0
+        ? await findUnusedPort()
+        : debugPort;
+    final args = [
+      // Using a tmp directory ensures that a new instance of chrome launches
+      // allowing for the remote debug port to be enabled.
+      '--user-data-dir=${dataDir.path}',
+      '--remote-debugging-port=$port',
+      // When the DevTools has focus we don't want to slow down the application.
+      '--disable-background-timer-throttling',
+      // Since we are using a temp profile, disable features that slow the
+      // Chrome launch.
+      '--disable-extensions',
+      '--disable-popup-blocking',
+      '--bwsi',
+      '--no-first-run',
+      '--no-default-browser-check',
+      '--disable-default-apps',
+      '--disable-translate',
+    ];
+    if (headless) {
+      args.add('--headless');
+    }
+
+    final process = await _startProcess(urls, args: args);
+
+    // Wait until the DevTools are listening before trying to connect.
+    await process.stderr
+        .transform(utf8.decoder)
+        .transform(const LineSplitter())
+        .firstWhere((line) => line.startsWith('DevTools listening'))
+        .timeout(Duration(seconds: 60),
+            onTimeout: () =>
+                throw Exception('Unable to connect to Chrome DevTools.'));
+
+    return _connect(Chrome._(
+      port,
+      ChromeConnection('localhost', port),
+      process: process,
+      dataDir: dataDir,
+    ));
+  }
+
+  /// Starts Chrome with the given arguments.
+  ///
+  /// Each url in [urls] will be loaded in a separate tab.
+  static Future<void> start(
+    List<String> urls, {
+    List<String> args = const [],
+  }) async {
+    await _startProcess(urls, args: args);
+  }
+
+  static Future<Process> _startProcess(
+    List<String> urls, {
+    List<String> args = const [],
+  }) async {
+    final processArgs = args.toList()..addAll(urls);
+    return await Process.start(_executable, processArgs);
+  }
+
+  static Future<Chrome> _connect(Chrome chrome) async {
+    // The connection is lazy. Try a simple call to make sure the provided
+    // connection is valid.
+    try {
+      await chrome.chromeConnection.getTabs();
+    } catch (e) {
+      await chrome.close();
+      throw ChromeError(
+          'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e');
+    }
+    return chrome;
+  }
+
+  Future<void> close() async {
+    chromeConnection.close();
+    _process?.kill(ProcessSignal.sigkill);
+    await _process?.exitCode;
+    try {
+      // Chrome starts another process as soon as it dies that modifies the
+      // profile information. Give it some time before attempting to delete
+      // the directory.
+      await Future.delayed(Duration(milliseconds: 500));
+      await _dataDir?.delete(recursive: true);
+    } catch (_) {
+      // Silently fail if we can't clean up the profile information.
+      // It is a system tmp directory so it should get cleaned up eventually.
+    }
+  }
+}
+
+class ChromeError extends Error {
+  final String details;
+  ChromeError(this.details);
+
+  @override
+  String toString() => 'ChromeError: $details';
+}
+
+/// Returns a port that is probably, but not definitely, not in use.
+///
+/// This has a built-in race condition: another process may bind this port at
+/// any time after this call has returned.
+Future<int> findUnusedPort() async {
+  int port;
+  ServerSocket socket;
+  try {
+    socket =
+        await ServerSocket.bind(InternetAddress.loopbackIPv6, 0, v6Only: true);
+  } on SocketException {
+    socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
+  }
+  port = socket.port;
+  await socket.close();
+  return port;
+}
diff --git a/pkgs/browser_launcher/pubspec.yaml b/pkgs/browser_launcher/pubspec.yaml
index ad8b8bb..f5cbaaf 100644
--- a/pkgs/browser_launcher/pubspec.yaml
+++ b/pkgs/browser_launcher/pubspec.yaml
@@ -10,6 +10,9 @@
   sdk: '>=2.2.0 <3.0.0'
 
 dependencies:
+  path: ^1.6.2
+  webkit_inspection_protocol: ^0.4.0
 
-dev_dependnecies:
+dev_dependencies:
   pedantic: ^1.5.0
+  test: ^1.0.0
diff --git a/pkgs/browser_launcher/test/chrome_test.dart b/pkgs/browser_launcher/test/chrome_test.dart
new file mode 100644
index 0000000..a63220f
--- /dev/null
+++ b/pkgs/browser_launcher/test/chrome_test.dart
@@ -0,0 +1,59 @@
+// Copyright (c) 2019, 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.
+
+@OnPlatform({'windows': Skip('appveyor is not setup to install Chrome')})
+import 'dart:async';
+
+import 'package:browser_launcher/src/chrome.dart';
+import 'package:test/test.dart';
+import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
+
+void main() {
+  Chrome chrome;
+
+  Future<void> launchChromeWithDebugPort({int port}) async {
+    chrome = await Chrome.startWithDebugPort([_googleUrl], debugPort: port);
+  }
+
+  Future<void> launchChrome() async {
+    await Chrome.start([_googleUrl]);
+  }
+
+  tearDown(() async {
+    await chrome?.close();
+    chrome = null;
+  });
+
+  test('can launch chrome', () async {
+    await launchChrome();
+    expect(chrome, isNull);
+  });
+
+  test('can launch chrome with debug port', () async {
+    await launchChromeWithDebugPort();
+    expect(chrome, isNotNull);
+  });
+
+  test('debugger is working', () async {
+    await launchChromeWithDebugPort();
+    var tabs = await chrome.chromeConnection.getTabs();
+    expect(
+        tabs,
+        contains(const TypeMatcher<ChromeTab>()
+            .having((t) => t.url, 'url', _googleUrl)));
+  });
+
+  test('uses open debug port if provided port is 0', () async {
+    await launchChromeWithDebugPort(port: 0);
+    expect(chrome.debugPort, isNot(equals(0)));
+  });
+
+  test('can provide a specific debug port', () async {
+    var port = await findUnusedPort();
+    await launchChromeWithDebugPort(port: port);
+    expect(chrome.debugPort, port);
+  });
+}
+
+const _googleUrl = 'https://www.google.com/';