diff --git a/.travis.yml b/.travis.yml index c936b6f..b4e1991 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ dart: - dev dart_task: - # - test + - test - dartanalyzer: --fatal-infos --fatal-warnings . matrix: diff --git a/lib/src/chrome.dart b/lib/src/chrome.dart index 79bf402..7f1250b 100644 --- a/lib/src/chrome.dart +++ b/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 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 startWithDebugPort( + List 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 start( + List urls, { + List args = const [], + }) async { + await _startProcess(urls, args: args); + } + + static Future _startProcess( + List urls, { + List args = const [], + }) async { + final processArgs = args.toList()..addAll(urls); + return await Process.start(_executable, processArgs); + } + + static Future _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 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 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/pubspec.yaml b/pubspec.yaml index ad8b8bb..f5cbaaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,9 @@ environment: 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/test/chrome_test.dart b/test/chrome_test.dart new file mode 100644 index 0000000..a63220f --- /dev/null +++ b/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 launchChromeWithDebugPort({int port}) async { + chrome = await Chrome.startWithDebugPort([_googleUrl], debugPort: port); + } + + Future 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() + .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/';