| // Copyright (c) 2015, 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:collection/collection.dart'; | |
| import 'package:coverage/coverage.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports | |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; | |
| import '../executable_settings.dart'; | |
| import 'browser.dart'; | |
| import 'chromium.dart'; | |
| import 'default_settings.dart'; | |
| /// A class for running an instance of Chrome. | |
| /// | |
| /// Most of the communication with the browser is expected to happen via HTTP, | |
| /// so this exposes a bare-bones API. The browser starts as soon as the class is | |
| /// constructed, and is killed when [close] is called. | |
| /// | |
| /// Any errors starting or running the process are reported through [onExit]. | |
| class Chrome extends Browser { | |
| @override | |
| final name = 'Chrome'; | |
| @override | |
| final Future<Uri?> remoteDebuggerUrl; | |
| final Future<WipConnection> _tabConnection; | |
| final Map<String, String> _idToUrl; | |
| /// Starts a new instance of Chrome open to the given [url], which may be a | |
| /// [Uri] or a [String]. | |
| factory Chrome( | |
| Uri url, | |
| Configuration configuration, { | |
| ExecutableSettings? settings, | |
| }) { | |
| settings ??= defaultSettings[Runtime.chrome]!; | |
| var remoteDebuggerCompleter = Completer<Uri?>.sync(); | |
| var connectionCompleter = Completer<WipConnection>(); | |
| var idToUrl = <String, String>{}; | |
| return Chrome._( | |
| () async { | |
| Future<Process> tryPort([int? port]) async { | |
| var process = await ChromiumBasedBrowser.chrome.spawn( | |
| url, | |
| configuration, | |
| settings: settings, | |
| additionalArgs: [ | |
| if (port != null) | |
| // Chrome doesn't provide any way of ensuring that this port was | |
| // successfully bound. It produces an error if the binding fails, | |
| // but without a reliable and fast way to tell if it succeeded | |
| // that doesn't provide us much. It's very unlikely that this port | |
| // will fail, though. | |
| '--remote-debugging-port=$port', | |
| ], | |
| ); | |
| if (port != null) { | |
| remoteDebuggerCompleter.complete( | |
| getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')), | |
| ); | |
| connectionCompleter.complete(_connect(process, port, idToUrl, url)); | |
| } else { | |
| remoteDebuggerCompleter.complete(null); | |
| } | |
| return process; | |
| } | |
| if (!configuration.debug) return tryPort(); | |
| return getUnusedPort<Process>(tryPort); | |
| }, | |
| remoteDebuggerCompleter.future, | |
| connectionCompleter.future, | |
| idToUrl, | |
| ); | |
| } | |
| /// Returns a Dart based hit-map containing coverage report, suitable for use | |
| /// with `package:coverage`. | |
| Future<Map<String, dynamic>> gatherCoverage() async { | |
| var tabConnection = await _tabConnection; | |
| var response = await tabConnection.debugger.connection.sendCommand( | |
| 'Profiler.takePreciseCoverage', | |
| {}, | |
| ); | |
| var result = (response.result!['result'] as List) | |
| .cast<Map<String, dynamic>>(); | |
| var httpClient = HttpClient(); | |
| var coverage = await parseChromeCoverage( | |
| result, | |
| (scriptId) => _sourceProvider(scriptId, httpClient), | |
| (scriptId) => _sourceMapProvider(scriptId, httpClient), | |
| _sourceUriProvider, | |
| ); | |
| httpClient.close(force: true); | |
| return coverage; | |
| } | |
| Chrome._( | |
| super.startBrowser, | |
| this.remoteDebuggerUrl, | |
| this._tabConnection, | |
| this._idToUrl, | |
| ); | |
| Future<Uri?> _sourceUriProvider(String sourceUrl, String scriptId) async { | |
| var script = _idToUrl[scriptId]; | |
| if (script == null) return null; | |
| var sourceUri = Uri.parse(sourceUrl); | |
| if (sourceUri.scheme == 'file') return sourceUri; | |
| // If the provided sourceUrl is relative, determine the package path. | |
| var uri = Uri.parse(script); | |
| var path = p.join( | |
| p.joinAll(uri.pathSegments.sublist(1, uri.pathSegments.length - 1)), | |
| sourceUrl, | |
| ); | |
| return path.contains('/packages/') | |
| ? Uri(scheme: 'package', path: path.split('/packages/').last) | |
| : null; | |
| } | |
| Future<String?> _sourceMapProvider( | |
| String scriptId, | |
| HttpClient httpClient, | |
| ) async { | |
| var script = _idToUrl[scriptId]; | |
| if (script == null) return null; | |
| return await httpClient.getString('$script.map'); | |
| } | |
| Future<String?> _sourceProvider( | |
| String scriptId, | |
| HttpClient httpClient, | |
| ) async { | |
| var script = _idToUrl[scriptId]; | |
| if (script == null) return null; | |
| return await httpClient.getString(script); | |
| } | |
| } | |
| Future<WipConnection> _connect( | |
| Process process, | |
| int port, | |
| Map<String, String> idToUrl, | |
| Uri url, | |
| ) async { | |
| // Wait for Chrome to be in a ready state. | |
| await process.stderr | |
| .transform(utf8.decoder) | |
| .transform(const LineSplitter()) | |
| .firstWhere((line) => line.startsWith('DevTools listening')); | |
| var chromeConnection = ChromeConnection('localhost', port); | |
| ChromeTab? tab; | |
| var attempt = 0; | |
| while (tab == null) { | |
| attempt++; | |
| var tabs = await chromeConnection.getTabs(); | |
| tab = tabs.firstWhereOrNull((tab) => tab.url == url.toString()); | |
| if (tab == null) { | |
| await Future<void>.delayed(const Duration(milliseconds: 200)); | |
| if (attempt > 20) { | |
| throw StateError('Could not connect to test tab with url: $url'); | |
| } | |
| } | |
| } | |
| var tabConnection = await tab.connect(); | |
| // Enable debugging. | |
| await tabConnection.debugger.enable(); | |
| // Coverage reports are in terms of scriptIds so keep note of URLs. | |
| tabConnection.debugger.onScriptParsed.listen((data) { | |
| var script = data.script; | |
| if (script.url.isNotEmpty) idToUrl[script.scriptId] = script.url; | |
| }); | |
| // Enable coverage collection. | |
| await tabConnection.debugger.connection.sendCommand('Profiler.enable', {}); | |
| await tabConnection.debugger.connection.sendCommand( | |
| 'Profiler.startPreciseCoverage', | |
| {'detailed': true, 'callCount': false}, | |
| ); | |
| return tabConnection; | |
| } | |
| extension on HttpClient { | |
| Encoding determineEncoding(HttpHeaders headers) { | |
| final contentType = headers.contentType?.charset; | |
| /// Using the `charset` property of the `contentType` if available. | |
| /// If it's unavailable or if the encoding name is unknown, [latin1] is used by default, | |
| /// as per [RFC 2616][]. | |
| /// | |
| /// [RFC 2616]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html | |
| return Encoding.getByName(contentType) ?? latin1; | |
| } | |
| Future<String?> getString(String url) async { | |
| final request = await getUrl(Uri.parse(url)); | |
| final response = await request.close(); | |
| if (response.statusCode != HttpStatus.ok) return null; | |
| var bytes = [await for (var chunk in response) ...chunk]; | |
| final encoding = determineEncoding(response.headers); | |
| return encoding.decode(bytes); | |
| } | |
| } |