diff --git a/pkgs/browser_launcher/.github/dependabot.yml b/pkgs/browser_launcher/.github/dependabot.yml new file mode 100644 index 000000000..5a11cf58c --- /dev/null +++ b/pkgs/browser_launcher/.github/dependabot.yml @@ -0,0 +1,15 @@ +# Set update schedule for GitHub Actions + +version: 2 +updates: + +- package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - autosubmit + groups: + github-actions: + patterns: + - "*" diff --git a/pkgs/browser_launcher/.github/workflows/dart.yml b/pkgs/browser_launcher/.github/workflows/dart.yml new file mode 100644 index 000000000..6e1bfe272 --- /dev/null +++ b/pkgs/browser_launcher/.github/workflows/dart.yml @@ -0,0 +1,37 @@ +name: Dart + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + DISPLAY: ':99' + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + sdk: [3.4, dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + + - run: dart pub get + id: install + + - run: dart format --output=none --set-exit-if-changed . + - run: dart analyze --fatal-infos + + - name: Run Xvfb + run: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + + - run: dart test diff --git a/pkgs/browser_launcher/.gitignore b/pkgs/browser_launcher/.gitignore new file mode 100644 index 000000000..ec8eae3f1 --- /dev/null +++ b/pkgs/browser_launcher/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool/ +.packages +pubspec.lock diff --git a/pkgs/browser_launcher/AUTHORS b/pkgs/browser_launcher/AUTHORS new file mode 100644 index 000000000..e8063a8cd --- /dev/null +++ b/pkgs/browser_launcher/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/browser_launcher/CHANGELOG.md b/pkgs/browser_launcher/CHANGELOG.md new file mode 100644 index 000000000..4fb8a354d --- /dev/null +++ b/pkgs/browser_launcher/CHANGELOG.md @@ -0,0 +1,67 @@ +## 1.1.2 + +- Require Dart 3.4 +- Log errors from chrome +- Allow tests to detect headless-only environment (for CI). +- Add extra flags that may help disable additional throttling in background tabs +- Add `--use-mock-keychain` flag to avoid blocking dialog on MacOS. + +## 1.1.1 + +- Populate the pubspec `repository` field. + +## 1.1.0 + +- Add optional `signIn` argument to `startWithDebugPort`. + To be used together with `user-data-dir` to start a Chrome + window signed in to the default profile with extensions enabled. +- Enable the `avoid_dynamic_calls` lint. + +## 1.0.0 + +- Migrate to null-safety. + +## 0.1.10 + +- Support `webkit_inspection_protocol` version `^1.0.0`. + +## 0.1.9 + +- Add support for Chrome executables in `CHROME_PATH`. + +## 0.1.8 + +- Log `STDERR` on Chrome launch failure. + +## 0.1.7 + +- Widen the dependency range on `package:webkit_inspection_protocol`. + +## 0.1.6 + +- Update lower Dart SDK requirement to `2.2.0`. +- Update the dependency range on `package:webkit_inspection_protocol`. + +## 0.1.5 + +- Add a parameter to use a specified user-data-dir instead of a system temp. + +## 0.1.4 + +- Start Chrome maximized. + +## 0.1.3 + +- widen the version constraint on `package:webkit_inspection_protocol` + +## 0.1.2 + +- lower min sdk version to match Flutter stable + +## 0.1.1 + +- added example + +## 0.1.0 + +- initial release diff --git a/pkgs/browser_launcher/LICENSE b/pkgs/browser_launcher/LICENSE new file mode 100644 index 000000000..767000764 --- /dev/null +++ b/pkgs/browser_launcher/LICENSE @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/browser_launcher/README.md b/pkgs/browser_launcher/README.md new file mode 100644 index 000000000..cf534ab3e --- /dev/null +++ b/pkgs/browser_launcher/README.md @@ -0,0 +1,8 @@ +[![Dart](https://github.com/dart-lang/browser_launcher/workflows/Dart/badge.svg)](https://github.com/dart-lang/browser_launcher/actions?query=workflow%3ADart+branch%3Amaster) +[![pub package](https://img.shields.io/pub/v/browser_launcher.svg)](https://pub.dev/packages/browser_launcher) +[![package publisher](https://img.shields.io/pub/publisher/browser_launcher.svg)](https://pub.dev/packages/browser_launcher/publisher) + +Provides a standardized way to launch web browsers. + +Currently, Chrome is the only supported browser; support for other browsers may +be added in the future. diff --git a/pkgs/browser_launcher/analysis_options.yaml b/pkgs/browser_launcher/analysis_options.yaml new file mode 100644 index 000000000..556f883c8 --- /dev/null +++ b/pkgs/browser_launcher/analysis_options.yaml @@ -0,0 +1,33 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - package_api_docs + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - require_trailing_commas + - unnecessary_raw_strings + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/browser_launcher/example/main.dart b/pkgs/browser_launcher/example/main.dart new file mode 100644 index 000000000..86b4eea3a --- /dev/null +++ b/pkgs/browser_launcher/example/main.dart @@ -0,0 +1,27 @@ +import 'package:browser_launcher/browser_launcher.dart'; + +const _googleUrl = 'https://www.google.com/'; +const _googleImagesUrl = 'https://www.google.com/imghp?hl=en'; + +Future main() async { + // Launches a chrome browser with two tabs open to [_googleUrl] and + // [_googleImagesUrl]. + await Chrome.start([_googleUrl, _googleImagesUrl]); + print('launched Chrome'); + + // Pause briefly before opening Chrome with a debug port. + await Future.delayed(const Duration(seconds: 3)); + + // Launches a chrome browser open to [_googleUrl]. Since we are launching with + // a debug port, we will use a variety of different launch configurations, + // such as launching in a new browser. + final chrome = await Chrome.startWithDebugPort([_googleUrl], debugPort: 8888); + print('launched Chrome with a debug port'); + + // When running this dart code, observe that the browser stays open for 3 + // seconds before we close it. + await Future.delayed(const Duration(seconds: 3)); + + await chrome.close(); + print('closed Chrome'); +} diff --git a/pkgs/browser_launcher/lib/browser_launcher.dart b/pkgs/browser_launcher/lib/browser_launcher.dart new file mode 100644 index 000000000..7d85dadce --- /dev/null +++ b/pkgs/browser_launcher/lib/browser_launcher.dart @@ -0,0 +1,5 @@ +// 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. + +export 'src/chrome.dart'; diff --git a/pkgs/browser_launcher/lib/src/chrome.dart b/pkgs/browser_launcher/lib/src/chrome.dart new file mode 100644 index 000000000..8ee14f05d --- /dev/null +++ b/pkgs/browser_launcher/lib/src/chrome.dart @@ -0,0 +1,237 @@ +// 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:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; + +const _chromeEnvironments = ['CHROME_EXECUTABLE', 'CHROME_PATH']; +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 { + for (var chromeEnv in _chromeEnvironments) { + if (Platform.environment.containsKey(chromeEnv)) { + return Platform.environment[chromeEnv]!; + } + } + 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 { + static final _logger = Logger('BROWSER_LAUNCHER.CHROME'); + + Chrome._( + this.debugPort, + this.chromeConnection, { + Process? process, + Directory? dataDir, + this.deleteDataDir = false, + }) : _process = process, + _dataDir = dataDir; + + final int debugPort; + final ChromeConnection chromeConnection; + final Process? _process; + final Directory? _dataDir; + final bool deleteDataDir; + + /// 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. + /// + /// Each url in [urls] will be loaded in a separate tab. + /// + /// If [userDataDir] is `null`, a new temp directory will be + /// passed to chrome as a user data directory. Chrome will + /// start without sign in and with extensions disabled. + /// + /// If [userDataDir] is not `null`, it will be passed to chrome + /// as a user data directory. Chrome will start signed into + /// the default profile with extensions enabled if [signIn] + /// is also true. + static Future startWithDebugPort( + List urls, { + int debugPort = 0, + bool headless = false, + String? userDataDir, + bool signIn = false, + }) async { + Directory dataDir; + if (userDataDir == null) { + signIn = false; + dataDir = Directory.systemTemp.createTempSync(); + } else { + dataDir = Directory(userDataDir); + } + final port = 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', + '--disable-blink-features=TimerThrottlingForBackgroundTabs', + '--disable-features=IntensiveWakeUpThrottling', + // Since we are using a temp profile, disable features that slow the + // Chrome launch. + if (!signIn) '--disable-extensions', + '--disable-popup-blocking', + if (!signIn) '--bwsi', + '--no-first-run', + '--no-default-browser-check', + '--disable-default-apps', + '--disable-translate', + '--start-maximized', + // When running on MacOS, Chrome may open system dialogs requesting + // credentials. This uses a mock keychain to avoid that dialog from + // blocking. + '--use-mock-keychain', + ]; + if (headless) { + args.add('--headless'); + } + + final process = await _startProcess(urls, args: args); + + // Wait until the DevTools are listening before trying to connect. + final errorLines = []; + try { + final stderr = process.stderr.asBroadcastStream(); + stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_logger.fine); + + await stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .firstWhere((line) { + errorLines.add(line); + return line.startsWith('DevTools listening'); + }).timeout(const Duration(seconds: 60)); + } on TimeoutException catch (e, s) { + _logger.severe('Unable to connect to Chrome DevTools', e, s); + throw Exception( + 'Unable to connect to Chrome DevTools: $e.\n\n' + 'Chrome STDERR:\n${errorLines.join('\n')}', + ); + } + + return _connect( + Chrome._( + port, + ChromeConnection('localhost', port), + process: process, + dataDir: dataDir, + deleteDataDir: userDataDir == null, + ), + ); + } + + /// 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. + if (deleteDataDir) { + await Future.delayed(const 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/pkgs/browser_launcher/pubspec.yaml b/pkgs/browser_launcher/pubspec.yaml new file mode 100644 index 000000000..39da2c9f7 --- /dev/null +++ b/pkgs/browser_launcher/pubspec.yaml @@ -0,0 +1,16 @@ +name: browser_launcher +version: 1.1.2 +description: Provides a standardized way to launch web browsers for testing and tools. +repository: https://github.com/dart-lang/browser_launcher + +environment: + sdk: ^3.4.0 + +dependencies: + logging: ^1.0.0 + path: ^1.8.0 + webkit_inspection_protocol: ^1.0.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.17.3 diff --git a/pkgs/browser_launcher/test/chrome_test.dart b/pkgs/browser_launcher/test/chrome_test.dart new file mode 100644 index 000000000..92437681a --- /dev/null +++ b/pkgs/browser_launcher/test/chrome_test.dart @@ -0,0 +1,226 @@ +// 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')}) +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:browser_launcher/src/chrome.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; + +const _headlessOnlyEnvironment = 'HEADLESS_ONLY'; + +bool get headlessOnlyEnvironment => + Platform.environment[_headlessOnlyEnvironment] == 'true'; + +void _configureLogging(bool verbose) { + Logger.root.level = verbose ? Level.ALL : Level.INFO; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); +} + +void main() { + Chrome? chrome; + + // Pass 'true' for debugging. + _configureLogging(false); + + Future getTab(String url) => chrome!.chromeConnection.getTab( + (t) => t.url.contains(url), + retryFor: const Duration(seconds: 5), + ); + + Future?> getTabs() => chrome!.chromeConnection.getTabs( + retryFor: const Duration(seconds: 5), + ); + + Future connectToTab(String url) async { + final tab = await getTab(url); + expect(tab, isNotNull); + return tab!.connect(); + } + + Future openTab(String url) => + chrome!.chromeConnection.getUrl(_openTabUrl(url)); + + Future launchChromeWithDebugPort({ + int port = 0, + String? userDataDir, + bool signIn = false, + bool headless = false, + }) async { + chrome = await Chrome.startWithDebugPort( + [_googleUrl], + debugPort: port, + userDataDir: userDataDir, + signIn: signIn, + headless: headless, + ); + } + + Future launchChrome({bool headless = false}) async { + await Chrome.start([_googleUrl], args: [if (headless) '--headless']); + } + + final headlessModes = [ + true, + if (!headlessOnlyEnvironment) false, + ]; + + for (var headless in headlessModes) { + group('(headless: $headless)', () { + group('chrome with temp data dir', () { + tearDown(() async { + await chrome?.close(); + chrome = null; + }); + + test('can launch chrome', () async { + await launchChrome(headless: headless); + expect(chrome, isNull); + }); + + test('can launch chrome with debug port', () async { + await launchChromeWithDebugPort(headless: headless); + expect(chrome, isNotNull); + }); + + test('has a working debugger', () async { + await launchChromeWithDebugPort(headless: headless); + final tabs = await 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(headless: headless); + expect(chrome!.debugPort, isNot(equals(0))); + }); + + test('can provide a specific debug port', () async { + final port = await findUnusedPort(); + await launchChromeWithDebugPort(port: port, headless: headless); + expect(chrome!.debugPort, port); + }); + }); + + group('chrome with user data dir', () { + late Directory dataDir; + const waitMilliseconds = Duration(milliseconds: 100); + + for (var signIn in [false, true]) { + group('and signIn = $signIn', () { + setUp(() { + dataDir = Directory.systemTemp.createTempSync(_userDataDirName); + }); + + tearDown(() async { + await chrome?.close(); + chrome = null; + + var attempts = 0; + while (true) { + try { + attempts++; + await Future.delayed(waitMilliseconds); + dataDir.deleteSync(recursive: true); + break; + } catch (_) { + if (attempts > 3) rethrow; + } + } + }); + + test('can launch with debug port', () async { + await launchChromeWithDebugPort( + userDataDir: dataDir.path, + signIn: signIn, + headless: headless, + ); + expect(chrome, isNotNull); + }); + + test('has a working debugger', () async { + await launchChromeWithDebugPort( + userDataDir: dataDir.path, + signIn: signIn, + headless: headless, + ); + final tabs = await getTabs(); + expect( + tabs, + contains( + const TypeMatcher() + .having((t) => t.url, 'url', _googleUrl), + ), + ); + }); + + test( + 'has correct profile path', + () async { + await launchChromeWithDebugPort( + userDataDir: dataDir.path, + signIn: signIn, + headless: headless, + ); + await openTab(_chromeVersionUrl); + final wipConnection = await connectToTab(_chromeVersionUrl); + await wipConnection.debugger.enable(); + await wipConnection.runtime.enable(); + final result = await _evaluate( + wipConnection.page, + "document.getElementById('profile_path').textContent", + ); + expect(result, contains(_userDataDirName)); + }, + // Note: When re-enabling, skip for headless mode because headless + // mode does not allow chrome: urls. + skip: 'https://github.com/dart-lang/sdk/issues/52357', + ); + }); + } + }); + }); + } +} + +String _openTabUrl(String url) => '/json/new?$url'; + +Future _evaluate(WipPage page, String expression) async { + String? result; + const stopInSeconds = Duration(seconds: 5); + const waitMilliseconds = Duration(milliseconds: 100); + final stopTime = DateTime.now().add(stopInSeconds); + + while (result == null && DateTime.now().isBefore(stopTime)) { + await Future.delayed(waitMilliseconds); + try { + final wipResponse = await page.sendCommand( + 'Runtime.evaluate', + params: {'expression': expression}, + ); + final response = wipResponse.json['result'] as Map; + final value = (response['result'] as Map)['value']; + result = value?.toString(); + } catch (_) { + return null; + } + } + return result; +} + +const _googleUrl = 'https://www.google.com/'; +const _chromeVersionUrl = 'chrome://version/'; +const _userDataDirName = 'data dir';