[web][identity] Test utility for driving Chrome instances from host

This will be used for driving Google login tests.

Testing: Running with WIP Google login test, which tests a single Chrome
instance displaying authentication for OAuth.  fx run-host-tests

WEB-40 #comment
AUTH-198 #comment

Change-Id: Ia013d2a11d80d2a03550f783007af7ce1e04dd8c
diff --git a/sdk/testing/sl4f/client/BUILD.gn b/sdk/testing/sl4f/client/BUILD.gn
index 24648f4..97e847b5 100644
--- a/sdk/testing/sl4f/client/BUILD.gn
+++ b/sdk/testing/sl4f/client/BUILD.gn
@@ -24,6 +24,7 @@
     "src/sl4f_client.dart",
     "src/storage.dart",
     "src/traceutil.dart",
+    "src/webdriver.dart",
   ]
 
   deps = [
@@ -31,6 +32,7 @@
     "//third_party/dart-pkg/pub/image",
     "//third_party/dart-pkg/pub/logging",
     "//third_party/dart-pkg/pub/meta",
+    "//third_party/dart-pkg/pub/webdriver",
   ]
 }
 
@@ -43,6 +45,7 @@
     "performance_test.dart",
     "scenic_test.dart",
     "storage_test.dart",
+    "webdriver_test.dart",
   ]
 
   deps = [
diff --git a/sdk/testing/sl4f/client/lib/sl4f.dart b/sdk/testing/sl4f/client/lib/sl4f.dart
index 84a7ff1..396d7c6 100644
--- a/sdk/testing/sl4f/client/lib/sl4f.dart
+++ b/sdk/testing/sl4f/client/lib/sl4f.dart
@@ -17,3 +17,4 @@
 export 'src/sl4f_client.dart';
 export 'src/storage.dart';
 export 'src/traceutil.dart';
+export 'src/webdriver.dart';
diff --git a/sdk/testing/sl4f/client/lib/src/webdriver.dart b/sdk/testing/sl4f/client/lib/src/webdriver.dart
new file mode 100644
index 0000000..7c6ddc1
--- /dev/null
+++ b/sdk/testing/sl4f/client/lib/src/webdriver.dart
@@ -0,0 +1,180 @@
+// 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:io' as io;
+
+import 'package:sl4f/sl4f.dart';
+import 'package:webdriver/sync_core.dart' show WebDriver;
+import 'package:webdriver/sync_io.dart' as sync_io;
+
+/// Port Chromedriver listens on.
+const _chromedriverPort = 9072;
+
+/// `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.
+///
+/// Note that the latest version of Chromedriver currently needs to be manually
+/// downloaded and placed in the location passed when constructing
+/// `WebDriverConnector`.  This is necessary as the automatically downloaded
+/// version in prebuilt is not kept up to date with the Chrome version. Effort
+/// for this is tracked in IN-1321.
+/// TODO(satsukiu): Remove notice and add e2e test for facade functionality
+/// on completion of IN-1321
+class WebDriverConnector {
+  /// Relative path of chromedriver binary.
+  final String _chromedriverPath;
+
+  /// SL4F client.
+  final Sl4f _sl4f;
+
+  /// Helper for starting processes.
+  final ProcessHelper _processHelper;
+
+  /// Helper for instantiating WebDriver objects.
+  final WebDriverHelper _webDriverHelper;
+
+  /// A handle to the process running Chromedriver.
+  io.Process _chromedriverProcess;
+
+  /// A mapping from an exposed port number to an open WebDriver session.
+  Map<int, _WebDriverSession> _webDriverSessions;
+
+  WebDriverConnector(String chromeDriverPath, Sl4f sl4f,
+      {ProcessHelper processHelper, WebDriverHelper webDriverHelper})
+      : _chromedriverPath = chromeDriverPath,
+        _sl4f = sl4f,
+        _processHelper = processHelper ?? ProcessHelper(),
+        _webDriverHelper = webDriverHelper ?? WebDriverHelper(),
+        _webDriverSessions = {};
+
+  /// Starts ChromeDriver 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 {
+    await _sl4f.request('webdriver_facade.EnableDevTools');
+    await _startChromedriver();
+  }
+
+  /// Stops Chromedriver and removes any connections that are still open.
+  void tearDown() {
+    _chromedriverProcess?.kill();
+    _chromedriverProcess = null;
+    for (var session in _webDriverSessions.values) {
+      session.portForwardingProcess.kill();
+    }
+    _webDriverSessions = {};
+  }
+
+  /// 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 {
+    await _updateWebDriverSessions();
+
+    return List.from(_webDriverSessions.values
+        .where(
+            (session) => Uri.parse(session.webDriver.currentUrl).host == host)
+        .map((session) => session.webDriver));
+  }
+
+  /// Starts Chromedriver on the host.
+  Future<void> _startChromedriver() async {
+    if (_chromedriverProcess == null) {
+      final chromedriver =
+          io.Platform.script.resolve(_chromedriverPath).toFilePath();
+      final args = ['--port=$_chromedriverPort'];
+      _chromedriverProcess = await _processHelper.start(chromedriver, args);
+    }
+  }
+
+  /// Updates the set of open WebDriver connections.
+  Future<void> _updateWebDriverSessions() async {
+    var portsResult = await _sl4f.request('webdriver_facade.GetDevToolsPorts');
+    var ports = Set.from(portsResult['ports']);
+
+    // terminate and remove port forwarding for any ports that aren't open anymore
+    _webDriverSessions.removeWhere((port, session) {
+      if (!ports.contains(port)) {
+        session.portForwardingProcess.kill();
+        return true;
+      } else {
+        return false;
+      }
+    });
+
+    // Add new sessions for new ports.  For a given Chrome context listening on
+    // port p on the DuT, we forward localhost:p to DuT:p, and create a
+    // WebDriver instance pointing to localhost:p.
+    for (var port in ports) {
+      if (!_webDriverSessions.containsKey(port)) {
+        var forwardProcess = await _processHelper.start(
+            'ssh',
+            [
+              '-i',
+              _sl4f.sshKeyPath,
+              '-N',
+              '-o',
+              'UserKnownHostsFile=/dev/null',
+              '-o',
+              'StrictHostKeyChecking=no',
+              '-L',
+              '$port:localhost:$port',
+              _sl4f.target,
+            ],
+            runInShell: true);
+
+        var webDriver = _webDriverHelper.createDriver(port, _chromedriverPort);
+
+        _webDriverSessions.putIfAbsent(
+            port, () => _WebDriverSession(webDriver, forwardProcess));
+      }
+    }
+  }
+}
+
+/// 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}) async {
+    return await 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(int devToolsPort, int chromedriverPort) {
+    var chromeOptions = {'debuggerAddress': 'localhost:$devToolsPort'};
+    var capabilities = sync_io.Capabilities.chrome;
+    capabilities[sync_io.Capabilities.chromeOptions] = chromeOptions;
+    return sync_io.createDriver(
+        desired: capabilities,
+        uri: Uri.parse('http://localhost:$_chromedriverPort'));
+  }
+}
+
+/// A wrapper around WebDriver object that also holds the port forwarding process.
+class _WebDriverSession {
+  /// WebDriver connection.
+  final WebDriver webDriver;
+
+  /// Handle to the process forwarding the port from the host to DuT.
+  final io.Process portForwardingProcess;
+
+  _WebDriverSession(this.webDriver, this.portForwardingProcess);
+}
diff --git a/sdk/testing/sl4f/client/test/webdriver_test.dart b/sdk/testing/sl4f/client/test/webdriver_test.dart
new file mode 100644
index 0000000..46875c8
--- /dev/null
+++ b/sdk/testing/sl4f/client/test/webdriver_test.dart
@@ -0,0 +1,69 @@
+// 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 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+import 'package:webdriver/sync_core.dart';
+
+import 'package:sl4f/sl4f.dart';
+
+class MockSl4f extends Mock implements Sl4f {}
+
+class MockProcessHelper extends Mock implements ProcessHelper {}
+
+class MockWebDriverHelper extends Mock implements WebDriverHelper {}
+
+class MockWebDriver extends Mock implements WebDriver {}
+
+void main(List<String> args) {
+  MockSl4f sl4f;
+  MockProcessHelper processHelper;
+  MockWebDriverHelper webDriverHelper;
+  WebDriverConnector webDriverConnector;
+
+  setUp(() {
+    sl4f = MockSl4f();
+    processHelper = MockProcessHelper();
+    webDriverHelper = MockWebDriverHelper();
+    webDriverConnector = WebDriverConnector('path/to/chromedriver', sl4f,
+        processHelper: processHelper, webDriverHelper: webDriverHelper);
+  });
+
+  test('webDriversForHost filters by host', () async {
+    var openContexts = {
+      20000: 'https://www.test.com/path/1',
+      20001: 'https://www.example.com/path/1',
+      20002: 'https://www.test.com/path/2',
+      20003: 'https://www.example.com/path/2'
+    };
+    mockAvailableWebDrivers(webDriverHelper, sl4f, openContexts);
+    var webDrivers = await webDriverConnector.webDriversForHost('www.test.com');
+    expect(webDrivers.length, 2);
+    var webDriverCurrentUrls =
+        Set.from(webDrivers.map((webDriver) => webDriver.currentUrl));
+    expect(webDriverCurrentUrls,
+        {'https://www.test.com/path/1', 'https://www.test.com/path/2'});
+  });
+
+  test('webDriversForHost no contexts', () async {
+    mockAvailableWebDrivers(webDriverHelper, sl4f, {});
+    var webDrivers = await webDriverConnector.webDriversForHost('www.test.com');
+    expect(webDrivers.length, 0);
+  });
+}
+
+/// Set up mocks as if there are chrome contexts with the given ports exposing a url.
+void mockAvailableWebDrivers(MockWebDriverHelper webDriverHelper, MockSl4f sl4f,
+    Map<int, String> portToUrl) {
+  var portList = {'ports': List.from(portToUrl.keys)};
+  print(portList);
+  when(sl4f.request('webdriver_facade.GetDevToolsPorts'))
+      .thenAnswer((_) => Future.value(portList));
+  when(webDriverHelper.createDriver(any, any)).thenAnswer((invocation) {
+    var devToolsPort = invocation.positionalArguments.first;
+    WebDriver webDriver = MockWebDriver();
+    when(webDriver.currentUrl).thenReturn(portToUrl[devToolsPort]);
+    return webDriver;
+  });
+}