|  | // Copyright 2019 The Fuchsia Authors. 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:convert'; | 
|  | import 'dart:io' as io; | 
|  |  | 
|  | import 'package:logging/logging.dart'; | 
|  | import 'package:sl4f/sl4f.dart'; | 
|  | import 'package:retry/retry.dart'; | 
|  | import 'package:webdriver/sync_core.dart' show WebDriver, NoSuchWindowException; | 
|  | import 'package:webdriver/sync_io.dart' as sync_io; | 
|  |  | 
|  | final _log = Logger('Webdriver'); | 
|  |  | 
|  | /// `WebDriverConnector` is a utility for host-driven tests that control Chrome | 
|  | /// contexts running on a remote device under test(DuT).  `WebDriverConnector` | 
|  | /// vends `WebDriver` objects connected to remote Chrome instances. | 
|  | /// Check the [webdriver package](https://pub.dev/documentation/webdriver/) | 
|  | /// documentation for details on using `WebDriver`. | 
|  | /// | 
|  | /// `WebDriverConnector` additionally starts an instance of the ChromeDriver | 
|  | /// binary that runs locally on the test host.  `WebDriver` instances | 
|  | /// communicate with ChromeDriver, which in turn communicates with Chrome | 
|  | /// instances on the DuT. | 
|  | /// | 
|  | /// TODO(satsukiu): Add e2e test for facade functionality | 
|  | class WebDriverConnector { | 
|  | /// Relative path of chromedriver binary, only provided if an existing | 
|  | /// chromedriver is not already running. | 
|  | final String _chromedriverPath; | 
|  |  | 
|  | /// SL4F client. | 
|  | final Sl4f _sl4f; | 
|  |  | 
|  | /// Helper for starting processes. | 
|  | /// | 
|  | /// Will be null if constructed with [fromExistingChromedriver()]. | 
|  | final ProcessHelper _processHelper; | 
|  |  | 
|  | /// Helper for instantiating WebDriver objects. | 
|  | final WebDriverHelper _webDriverHelper; | 
|  |  | 
|  | /// Helper for forwarding ports. | 
|  | final PortForwarder _portForwarder; | 
|  | PortForwarder get portForwarder => _portForwarder; | 
|  |  | 
|  | /// A handle to the process running Chromedriver. | 
|  | /// | 
|  | /// Will be null if constructed using [fromExistingChromedriver()], or if | 
|  | /// [initialize()] hasn't been called. | 
|  | io.Process _chromedriverProcess; | 
|  | io.Process get chromedriverProcess => _chromedriverProcess; | 
|  |  | 
|  | /// The port Chromedriver is listening on. | 
|  | /// | 
|  | /// If the [fromExistingChromedriver()] constructor is used, Chromedriver has | 
|  | /// already started; use that port. If it isn't passed in, an unused port is | 
|  | /// picked. | 
|  | int _chromedriverPort; | 
|  |  | 
|  | /// A mapping from a target port number on the DUT to an open WebDriver | 
|  | /// session. | 
|  | Map<int, WebDriverSession> _webDriverSessions; | 
|  |  | 
|  | WebDriverConnector(String chromeDriverPath, Sl4f sl4f, | 
|  | {ProcessHelper processHelper, | 
|  | WebDriverHelper webDriverHelper, | 
|  | PortForwarder portForwarder}) | 
|  | : _chromedriverPath = chromeDriverPath, | 
|  | _sl4f = sl4f, | 
|  | _processHelper = processHelper ?? ProcessHelper(), | 
|  | _webDriverHelper = webDriverHelper ?? WebDriverHelper(), | 
|  | _portForwarder = portForwarder ?? PortForwarder.fromSl4f(sl4f), | 
|  | _webDriverSessions = {}; | 
|  |  | 
|  | WebDriverConnector.fromExistingChromedriver(int chromedriverPort, Sl4f sl4f) | 
|  | : _chromedriverPort = chromedriverPort, | 
|  | _sl4f = sl4f, | 
|  | _webDriverHelper = WebDriverHelper(), | 
|  | _webDriverSessions = {}, | 
|  | _portForwarder = PortForwarder.fromSl4f(sl4f), | 
|  | // Chromedriver is already running so the below are set to null. | 
|  | _chromedriverPath = null, | 
|  | _processHelper = null; | 
|  |  | 
|  | /// Starts ChromeDriver (if not already running) and enables DevTools for any | 
|  | /// future created Chrome contexts. | 
|  | /// | 
|  | /// As this will not enable DevTools on any already opened contexts, | 
|  | /// `initialize` must be called prior to the instantiation of the Chrome | 
|  | /// context that needs to be driven. | 
|  | Future<void> initialize() async { | 
|  | if (_chromedriverPort == null) { | 
|  | await _startChromedriver(); | 
|  | } | 
|  | // TODO(satsukiu): return a nicer error, or don't fail if devtools is already enabled | 
|  | await _sl4f.request('webdriver_facade.EnableDevTools'); | 
|  | } | 
|  |  | 
|  | /// Stops Chromedriver and removes any connections that are still open. | 
|  | Future<void> tearDown() async { | 
|  | if (_chromedriverProcess != null) { | 
|  | _log.info('Stopping chromedriver'); | 
|  | _chromedriverProcess.kill(); | 
|  | await _chromedriverProcess.exitCode.timeout(Duration(seconds: 5), | 
|  | onTimeout: () { | 
|  | _log.warning('Chromedriver did not shut down, killing it.'); | 
|  | _chromedriverProcess.kill(io.ProcessSignal.sigkill); | 
|  | return _chromedriverProcess.exitCode; | 
|  | }); | 
|  | _chromedriverProcess = null; | 
|  | _chromedriverPort = null; | 
|  | } | 
|  |  | 
|  | for (final session in _webDriverSessions.entries) { | 
|  | await _portForwarder.stopPortForwarding( | 
|  | session.value.accessPoint, session.key); | 
|  | } | 
|  | _webDriverSessions = {}; | 
|  | await _portForwarder.tearDown(); | 
|  | } | 
|  |  | 
|  | /// Get all nonEmpty Urls obtained from current _webDriverSessions. | 
|  | Iterable<String> get sessionsUrls => _webDriverSessions.values | 
|  | .map((session) => session.webDriver.currentUrl) | 
|  | .where((url) => url.isNotEmpty); | 
|  |  | 
|  | /// Search for Chrome contexts based on the host of the currently displayed | 
|  | /// page and return their entries. | 
|  | /// | 
|  | /// For a returned entry, entry.key is the port, and entry.value is the | 
|  | /// WebDriver object. | 
|  | Future<List<WebDriverSession>> _webDriverSessionsForHost(String host) async { | 
|  | await _updateWebDriverSessions(); | 
|  | return List.from(_webDriverSessions.values.where( | 
|  | (session) => Uri.parse(session.webDriver.currentUrl).host == host)); | 
|  | } | 
|  |  | 
|  | /// Searches for Chrome contexts based on the host of the currently displayed | 
|  | /// page, and returns `WebDriver` connections to the found contexts. | 
|  | Future<List<WebDriver>> webDriversForHost(String host) async { | 
|  | _log.info('Finding webdrivers for $host'); | 
|  | return List.from((await _webDriverSessionsForHost(host)) | 
|  | .map((session) => session.webDriver)); | 
|  | } | 
|  |  | 
|  | /// Checks whether a debugging [endpoint] matches the specified [filters]. | 
|  | /// | 
|  | /// To match, for each (key, value) pair in [filters] must have key present in | 
|  | /// the [endpoint] object, and the corresponding values must be equal. | 
|  | bool _checkDebuggerEndpointFilters( | 
|  | Map<String, dynamic> endpoint, Map<String, dynamic> filters) { | 
|  | if (filters == null) { | 
|  | return true; | 
|  | } | 
|  | for (final key in filters.keys) { | 
|  | if (!endpoint.containsKey(key) || endpoint[key] != filters[key]) { | 
|  | return false; | 
|  | } | 
|  | } | 
|  | return true; | 
|  | } | 
|  |  | 
|  | /// Obtains a list of URLs for connecting to the DevTools debugger via | 
|  | /// websockets. | 
|  | /// | 
|  | /// The websocket targets are pulled from [the /json | 
|  | /// endpoint](https://chromedevtools.github.io/devtools-protocol/#endpoints). | 
|  | /// To be returned, an endpoint's url field must match the specified [host], | 
|  | /// and for each (key, value) pair in [filters], the corresponding | 
|  | /// field in the endpoint description must be present and equal to the | 
|  | /// specified value. | 
|  | /// | 
|  | /// This may return more than one URL per context, as a context can have | 
|  | /// multiple debugging targets, and more than one of them may match the | 
|  | /// given host. | 
|  | Future<List<String>> webSocketDebuggerUrlsForHost(String host, | 
|  | {Map<String, dynamic> filters}) async { | 
|  | final accessPointsForHost = (await _webDriverSessionsForHost(host)) | 
|  | .map((session) => session.accessPoint); | 
|  |  | 
|  | final devToolsUrls = <String>[]; | 
|  | for (final accessPoint in accessPointsForHost) { | 
|  | final request = await io.HttpClient().getUrl(Uri( | 
|  | scheme: 'http', | 
|  | host: accessPoint.host, | 
|  | port: accessPoint.port, | 
|  | path: 'json')); | 
|  | final response = await request.close(); | 
|  | final endpoints = json.decode(await utf8.decodeStream(response)); | 
|  |  | 
|  | for (final endpoint in endpoints) { | 
|  | if (_checkDebuggerEndpointFilters(endpoint, filters)) { | 
|  | devToolsUrls.add(endpoint['webSocketDebuggerUrl']); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | return devToolsUrls; | 
|  | } | 
|  |  | 
|  | /// Starts Chromedriver on the host. | 
|  | Future<void> _startChromedriver() async { | 
|  | if (_chromedriverProcess == null) { | 
|  | _chromedriverPort = await _sl4f.ssh.pickUnusedPort(); | 
|  |  | 
|  | final chromedriver = | 
|  | io.Platform.script.resolve(_chromedriverPath).toFilePath(); | 
|  | final args = ['--port=$_chromedriverPort']; | 
|  | _chromedriverProcess = await _processHelper.start(chromedriver, args); | 
|  | _chromedriverProcess.stderr | 
|  | .transform(utf8.decoder) | 
|  | .transform(const LineSplitter()) | 
|  | .listen((error) { | 
|  | _log.info('[Chromedriver] $error'); | 
|  | }); | 
|  |  | 
|  | _chromedriverProcess.stdout | 
|  | .transform(utf8.decoder) | 
|  | .transform(const LineSplitter()) | 
|  | .listen((log) { | 
|  | _log.info('[Chromedriver] $log'); | 
|  | }); | 
|  | } | 
|  | } | 
|  |  | 
|  | /// Updates the set of open WebDriver connections. | 
|  | Future<void> _updateWebDriverSessions() async { | 
|  | final remotePortsResult = | 
|  | await _sl4f.request('webdriver_facade.GetDevToolsPorts'); | 
|  |  | 
|  | final ports = Set.from(remotePortsResult['ports']); | 
|  |  | 
|  | // Remove port forwarding for any ports that aren't open or shown. | 
|  | _webDriverSessions.removeWhere((port, session) { | 
|  | if (!ports.contains(port) || !_isSessionDisplayed(session)) { | 
|  | _portForwarder.stopPortForwarding(session.accessPoint, port); | 
|  | return true; | 
|  | } | 
|  | return false; | 
|  | }); | 
|  |  | 
|  | // Add new sessions for new ports. | 
|  | for (final remotePort in ports) { | 
|  | final webDriverSession = await _createWebDriverSession(remotePort); | 
|  | _webDriverSessions.putIfAbsent(remotePort, () => webDriverSession); | 
|  | } | 
|  | } | 
|  |  | 
|  | bool _isSessionDisplayed(WebDriverSession session) { | 
|  | try { | 
|  | session.webDriver.window; | 
|  | } on NoSuchWindowException { | 
|  | return false; | 
|  | } | 
|  |  | 
|  | return true; | 
|  | } | 
|  |  | 
|  | /// Creates a `Webdriver` connection using the specified port.  Retries | 
|  | /// on errors that may occur due to network issues. | 
|  | Future<WebDriverSession> _createWebDriverSession(int remotePort, | 
|  | {int tries = 5}) async { | 
|  | final accessPoint = await _portForwarder.forwardPort(remotePort); | 
|  | final webDriver = await retry( | 
|  | () => _webDriverHelper.createDriver(accessPoint, _chromedriverPort), | 
|  | maxAttempts: tries, | 
|  | ); | 
|  |  | 
|  | return WebDriverSession(accessPoint, webDriver); | 
|  | } | 
|  | } | 
|  |  | 
|  | /// A host and port pair. | 
|  | class HostAndPort { | 
|  | final String host; | 
|  | final int port; | 
|  | HostAndPort(this.host, this.port); | 
|  | } | 
|  |  | 
|  | /// A representation of a `WebDriver` connection from a host device to a DUT. | 
|  | class WebDriverSession { | 
|  | /// The host and port through which the debug port is accessible. | 
|  | final HostAndPort accessPoint; | 
|  |  | 
|  | /// The webdriver connection. | 
|  | final WebDriver webDriver; | 
|  |  | 
|  | WebDriverSession(this.accessPoint, this.webDriver); | 
|  | } | 
|  |  | 
|  | abstract class PortForwarder { | 
|  | factory PortForwarder.fromSl4f(Sl4f sl4f) { | 
|  | // Chromedriver can't handle zone-id in ipv6 addresses. Since the TCP proxy requires | 
|  | // Chromedriver to call the target address, fall back to ssh in the cases Chromedriver can't | 
|  | // handle. | 
|  | if (sl4f.target.startsWith('[') && sl4f.target.contains('%')) { | 
|  | _log.warning('Using SSH to forward webdriver ports.'); | 
|  | return SshPortForwarder(sl4f); | 
|  | } | 
|  | return TcpPortForwarder(sl4f); | 
|  | } | 
|  |  | 
|  | /// Open a tunnel to `targetPort` on the DUT. Returns the host and port through | 
|  | /// which the tunnel is accessible. | 
|  | Future<HostAndPort> forwardPort(int targetPort); | 
|  |  | 
|  | /// Stop forwarding a port previously opened with `forwardPort`. | 
|  | Future<void> stopPortForwarding(HostAndPort openAddr, int targetPort); | 
|  |  | 
|  | /// Stop all proxies. Intended as a teardown step to clean up remaining proxies at the end of a | 
|  | /// test. | 
|  | Future<void> tearDown(); | 
|  | } | 
|  |  | 
|  | /// A PortForwarder that uses SSH. | 
|  | class SshPortForwarder implements PortForwarder { | 
|  | final Sl4f _sl4f; | 
|  |  | 
|  | @override | 
|  | Future<HostAndPort> forwardPort(int targetPort) async { | 
|  | final openPort = await _sl4f.ssh.forwardPort(remotePort: targetPort); | 
|  | return HostAndPort('localhost', openPort); | 
|  | } | 
|  |  | 
|  | @override | 
|  | Future<void> stopPortForwarding(HostAndPort openAddr, int targetPort) async => | 
|  | await _sl4f.ssh | 
|  | .cancelPortForward(port: openAddr.port, remotePort: targetPort); | 
|  |  | 
|  | @override | 
|  | Future<void> tearDown() async {} | 
|  |  | 
|  | SshPortForwarder(this._sl4f); | 
|  | } | 
|  |  | 
|  | /// A PortForwarder that uses the TCP proxy on the DUT. | 
|  | class TcpPortForwarder implements PortForwarder { | 
|  | final String _target; | 
|  | final TcpProxyController _proxyControl; | 
|  |  | 
|  | @override | 
|  | Future<HostAndPort> forwardPort(int targetPort) async { | 
|  | final openPort = await _proxyControl.openProxy(targetPort); | 
|  | return HostAndPort(_target, openPort); | 
|  | } | 
|  |  | 
|  | @override | 
|  | Future<void> stopPortForwarding(HostAndPort openAddr, int targetPort) async => | 
|  | await _proxyControl.dropProxy(targetPort); | 
|  |  | 
|  | @override | 
|  | Future<void> tearDown() async => await _proxyControl.stopAllProxies(); | 
|  |  | 
|  | TcpPortForwarder(Sl4f sl4f) | 
|  | : _proxyControl = TcpProxyController(sl4f), | 
|  | _target = sl4f.target; | 
|  | } | 
|  |  | 
|  | /// A wrapper around static dart:io Process methods. | 
|  | class ProcessHelper { | 
|  | ProcessHelper(); | 
|  |  | 
|  | /// Start a new process. | 
|  | Future<io.Process> start(String cmd, List<String> args, | 
|  | {bool runInShell = false}) => | 
|  | io.Process.start(cmd, args, runInShell: runInShell); | 
|  | } | 
|  |  | 
|  | /// A wrapper around static WebDriver creation methods. | 
|  | class WebDriverHelper { | 
|  | WebDriverHelper(); | 
|  |  | 
|  | /// Create a new WebDriver pointing to Chromedriver on the given uri and with | 
|  | /// given desired capabilities. | 
|  | WebDriver createDriver(HostAndPort debuggerAddress, int chromedriverPort) { | 
|  | final chromeOptions = { | 
|  | 'debuggerAddress': '${debuggerAddress.host}:${debuggerAddress.port}' | 
|  | }; | 
|  | final capabilities = sync_io.Capabilities.chrome; | 
|  | capabilities[sync_io.Capabilities.chromeOptions] = chromeOptions; | 
|  | return sync_io.createDriver( | 
|  | desired: capabilities, | 
|  | uri: Uri.parse('http://localhost:$chromedriverPort')); | 
|  | } | 
|  | } |