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/';