Skip to content
This repository has been archived by the owner on Oct 28, 2024. It is now read-only.

Add chrome launching code to browser_launcher #4

Merged
merged 13 commits into from
Apr 26, 2019
Merged
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ dart:
- dev

dart_task:
# - test
- test
- dartanalyzer: --fatal-infos --fatal-warnings .

matrix:
Expand Down
183 changes: 183 additions & 0 deletions lib/src/chrome.dart
Original file line number Diff line number Diff line change
@@ -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
kenzieschmoll marked this conversation as resolved.
Show resolved Hide resolved
.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;
}
5 changes: 4 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions test/chrome_test.dart
Original file line number Diff line number Diff line change
@@ -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/';