Skip to content
This repository was 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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
.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/';