Skip to content
This repository was archived by the owner on Oct 28, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 5 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
237 changes: 237 additions & 0 deletions lib/src/chrome.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,240 @@
// 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';
const _windowsPrefixes = ['LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)'];

String get _executable {
final windowsPrefixes =
_windowsPrefixes.map((name) => Platform.environment[name]).toList();
if (Platform.environment.containsKey(_chromeEnvironment)) {
return Platform.environment[_chromeEnvironment];
}
if (Platform.isLinux) return _linuxExecutable;
if (Platform.isMacOS) return _macOSExecutable;
if (Platform.isWindows) {
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.');
}

var _currentCompleter = Completer<Chrome>();

/// A class for managing an instance of Chrome.
class Chrome {
Chrome._(
this.debugPort,
this.chromeConnection, {
Process process,
}) : _process = process;

final int debugPort;
final Process _process;
final ChromeConnection chromeConnection;

/// Connects to an instance of Chrome with an open debug port.
static Future<Chrome> fromExisting(int port) async =>
_connect(Chrome._(port, ChromeConnection('localhost', port)));

static Future<Chrome> get connectedInstance => _currentCompleter.future;

/// Starts Chrome with the given arguments and a specific port.
///
/// Each url in [urls] will be loaded in a separate tab.
static Future<Chrome> startWithPort(
List<String> urls, {
String userDataDir,
int remoteDebuggingPort,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to be in the business of keeping track of all Chrome configuration. There are a ton of possible flags. We should probably make this more generic and just use an iterable of strings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made these named params per Nate's comment on the proposal doc:

"if there are common args that multiple usages need to have knowledge of it would be cool to have named args or something here."

@natebosch am I misunderstanding your request? Or do you think there are some that should be named and some that should not?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to a list of strings. If we want to make some named in the future, we can do that as needed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are args that multiple clients need to know about I'd be in favor of encoding knowledge here. @grouma what is your concern? Why do you prefer to duplicate this knowledge in other clients?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concerns don't need to be blocking - as you mention we can always add new named args in the future.

Copy link
Contributor

@grouma grouma Apr 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nate and I discussed offline.

My concern was having a significant list of unused optional arguments and trying to keep that in sync with the available Chrome flags. Right now we have three potential clients of this package, package:test, package:webdev and DevTools. From what I can tell the only arguments that are really configured / changed are --headless (which includes others by default) and --remote-debugging-port.

We agreed that the best approach is to provide optional arguments for those two options only. We will still pass the other options and just document their use. If and when other clients need to configure more options we can easily add another optional argument in a non-breaking way.

Ignore my suggestion of providing a mechanism to add arbitrary arguments. We can revisit that if necessary but it won't be likely given the low number of users for this package.

Sorry for the churn!

bool disableBackgroundTimerThrottling = false,
bool disableExtensions = false,
bool disablePopupBlocking = false,
bool bwsi = false,
bool noFirstRun = false,
bool noDefaultBrowserCheck = false,
bool disableDefaultApps = false,
bool disableTranslate = false,
}) async {
final port = remoteDebuggingPort == null || remoteDebuggingPort == 0
? await findUnusedPort()
: remoteDebuggingPort;

final process = await _startProcess(
urls,
userDataDir: userDataDir,
remoteDebuggingPort: port,
disableBackgroundTimerThrottling: disableBackgroundTimerThrottling,
disableExtensions: disableExtensions,
disablePopupBlocking: disablePopupBlocking,
bwsi: bwsi,
noFirstRun: noFirstRun,
noDefaultBrowserCheck: noDefaultBrowserCheck,
disableDefaultApps: disableDefaultApps,
disableTranslate: disableTranslate,
);

// 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,
));
}

/// Starts Chrome with the given arguments.
///
/// Each url in [urls] will be loaded in a separate tab.
static Future<void> start(
List<String> urls, {
String userDataDir,
int remoteDebuggingPort,
bool disableBackgroundTimerThrottling = false,
bool disableExtensions = false,
bool disablePopupBlocking = false,
bool bwsi = false,
bool noFirstRun = false,
bool noDefaultBrowserCheck = false,
bool disableDefaultApps = false,
bool disableTranslate = false,
}) async {
await _startProcess(
urls,
userDataDir: userDataDir,
remoteDebuggingPort: remoteDebuggingPort,
disableBackgroundTimerThrottling: disableBackgroundTimerThrottling,
disableExtensions: disableExtensions,
disablePopupBlocking: disablePopupBlocking,
bwsi: bwsi,
noFirstRun: noFirstRun,
noDefaultBrowserCheck: noDefaultBrowserCheck,
disableDefaultApps: disableDefaultApps,
disableTranslate: disableTranslate,
);
}

static Future<Process> _startProcess(
List<String> urls, {
String userDataDir,
int remoteDebuggingPort,
bool disableBackgroundTimerThrottling = false,
bool disableExtensions = false,
bool disablePopupBlocking = false,
bool bwsi = false,
bool noFirstRun = false,
bool noDefaultBrowserCheck = false,
bool disableDefaultApps = false,
bool disableTranslate = false,
}) async {
final List<String> args = [];
if (userDataDir != null) {
args.add('--user-data-dir=$userDataDir');
}
if (remoteDebuggingPort != null) {
args.add('--remote-debugging-port=$remoteDebuggingPort');
}
if (disableBackgroundTimerThrottling) {
args.add('--disable-background-timer-throttling');
}
if (disableExtensions) {
args.add('--disable-extensions');
}
if (disablePopupBlocking) {
args.add('--disable-popup-blocking');
}
if (bwsi) {
args.add('--bwsi');
}
if (noFirstRun) {
args.add('--no-first-run');
}
if (noDefaultBrowserCheck) {
args.add('--no-default-browser-check');
}
if (disableDefaultApps) {
args.add('--disable-default-apps');
}
if (disableTranslate) {
args.add('--disable-translate');
}
args..addAll(urls);

final process = await Process.start(_executable, args);

return process;
}

static Future<Chrome> _connect(Chrome chrome) async {
if (_currentCompleter.isCompleted) {
throw ChromeError('Only one instance of chrome can be started.');
}
// 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');
}
_currentCompleter.complete(chrome);
return chrome;
}

Future<void> close() async {
if (_currentCompleter.isCompleted) _currentCompleter = Completer<Chrome>();
chromeConnection.close();
_process?.kill();
await _process?.exitCode;
}
}

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
69 changes: 69 additions & 0 deletions test/chrome_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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:io';

import 'package:browser_launcher/src/chrome.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

void main() {
Chrome chrome;

Future<void> launchChromeWithDebugPort({int port}) async {
final dataDir = Directory(p.joinAll(
[Directory.current.path, '.dart_tool', 'webdev', 'chrome_profile']))
..createSync(recursive: true);
chrome = await Chrome.startWithPort(
[_googleUrl],
userDataDir: dataDir.path,
remoteDebuggingPort: port,
disableBackgroundTimerThrottling: true,
disableExtensions: true,
disablePopupBlocking: true,
bwsi: true,
noFirstRun: true,
noDefaultBrowserCheck: true,
disableDefaultApps: true,
disableTranslate: true,
);
}

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);
}, onPlatform: {'windows': Skip('appveyor is not setup to install Chrome')});

test('can launch chrome with debug port', () async {
await launchChromeWithDebugPort();
expect(chrome, isNotNull);
}, onPlatform: {'windows': Skip('appveyor is not setup to install Chrome')});

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)));
}, onPlatform: {'windows': Skip('appveyor is not setup to install Chrome')});

test('uses open debug port if provided port is 0', () async {
await launchChromeWithDebugPort(port: 0);
expect(chrome.debugPort, isNot(equals(0)));
}, onPlatform: {'windows': Skip('appveyor is not setup to install Chrome')});
}

const _googleUrl = 'https://www.google.com/';