blob: d9def57582976ec12bc00a2ccde5ddd5371061f9 [file] [log] [blame]
// 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.
final removedSessions = [];
_webDriverSessions.removeWhere((port, session) {
if (!ports.contains(port) || !_isSessionDisplayed(session)) {
removedSessions.add(MapEntry(port, session));
return true;
}
return false;
});
for (final removedSession in removedSessions) {
await _portForwarder.stopPortForwarding(
removedSession.value.accessPoint, removedSession.key);
}
// Add new sessions for new ports.
for (final remotePort in ports) {
if (!_webDriverSessions.containsKey(remotePort)) {
final webDriverSession = await _createWebDriverSession(remotePort);
_webDriverSessions[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 = sl4f.proxy,
_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'));
}
}