// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

/**
 * @fileoverview Defines a {@linkplain Driver WebDriver} client for the Chrome
 * web browser. Before using this module, you must download the latest
 * [ChromeDriver release] and ensure it can be found on your system [PATH].
 *
 * There are three primary classes exported by this module:
 *
 * 1. {@linkplain ServiceBuilder}: configures the
 *     {@link selenium-webdriver/remote.DriverService remote.DriverService}
 *     that manages the [ChromeDriver] child process.
 *
 * 2. {@linkplain Options}: defines configuration options for each new Chrome
 *     session, such as which {@linkplain Options#setProxy proxy} to use,
 *     what {@linkplain Options#addExtensions extensions} to install, or
 *     what {@linkplain Options#addArguments command-line switches} to use when
 *     starting the browser.
 *
 * 3. {@linkplain Driver}: the WebDriver client; each new instance will control
 *     a unique browser session with a clean user profile (unless otherwise
 *     configured through the {@link Options} class).
 *
 * __Customizing the ChromeDriver Server__ <a id="custom-server"></a>
 *
 * By default, every Chrome session will use a single driver service, which is
 * started the first time a {@link Driver} instance is created and terminated
 * when this process exits. The default service will inherit its environment
 * from the current process and direct all output to /dev/null. You may obtain
 * a handle to this default service using
 * {@link #getDefaultService getDefaultService()} and change its configuration
 * with {@link #setDefaultService setDefaultService()}.
 *
 * You may also create a {@link Driver} with its own driver service. This is
 * useful if you need to capture the server's log output for a specific session:
 *
 *     var chrome = require('selenium-webdriver/chrome');
 *
 *     var service = new chrome.ServiceBuilder()
 *         .loggingTo('/my/log/file.txt')
 *         .enableVerboseLogging()
 *         .build();
 *
 *     var options = new chrome.Options();
 *     // configure browser options ...
 *
 *     var driver = new chrome.Driver(options, service);
 *
 * Users should only instantiate the {@link Driver} class directly when they
 * need a custom driver service configuration (as shown above). For normal
 * operation, users should start Chrome using the
 * {@link selenium-webdriver.Builder}.
 *
 * __Working with Android__ <a id="android"></a>
 *
 * The [ChromeDriver][android] supports running tests on the Chrome browser as
 * well as [WebView apps][webview] starting in Android 4.4 (KitKat). In order to
 * work with Android, you must first start the adb
 *
 *     adb start-server
 *
 * By default, adb will start on port 5037. You may change this port, but this
 * will require configuring a [custom server](#custom-server) that will connect
 * to adb on the {@linkplain ServiceBuilder#setAdbPort correct port}:
 *
 *     var service = new chrome.ServiceBuilder()
 *         .setAdbPort(1234)
 *         build();
 *     // etc.
 *
 * The ChromeDriver may be configured to launch Chrome on Android using
 * {@link Options#androidChrome()}:
 *
 *     var driver = new Builder()
 *         .forBrowser('chrome')
 *         .setChromeOptions(new chrome.Options().androidChrome())
 *         .build();
 *
 * Alternatively, you can configure the ChromeDriver to launch an app with a
 * Chrome-WebView by setting the {@linkplain Options#androidActivity
 * androidActivity} option:
 *
 *     var driver = new Builder()
 *         .forBrowser('chrome')
 *         .setChromeOptions(new chrome.Options()
 *             .androidPackage('com.example')
 *             .androidActivity('com.example.Activity'))
 *         .build();
 *
 * [Refer to the ChromeDriver site] for more information on using the
 * [ChromeDriver with Android][android].
 *
 * [ChromeDriver]: https://sites.google.com/a/chromium.org/chromedriver/
 * [ChromeDriver release]: http://chromedriver.storage.googleapis.com/index.html
 * [PATH]: http://en.wikipedia.org/wiki/PATH_%28variable%29
 * [android]: https://sites.google.com/a/chromium.org/chromedriver/getting-started/getting-started---android
 * [webview]: https://developer.chrome.com/multidevice/webview/overview
 */

'use strict';

var fs = require('fs'),
    util = require('util');

var webdriver = require('./index'),
    executors = require('./executors'),
    io = require('./io'),
    portprober = require('./net/portprober'),
    remote = require('./remote');


/**
 * Name of the ChromeDriver executable.
 * @type {string}
 * @const
 */
var CHROMEDRIVER_EXE =
    process.platform === 'win32' ? 'chromedriver.exe' : 'chromedriver';


/**
 * Creates {@link selenium-webdriver/remote.DriverService} instances that manage
 * a [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/)
 * server in a child process.
 *
 * @param {string=} opt_exe Path to the server executable to use. If omitted,
 *     the builder will attempt to locate the chromedriver on the current
 *     PATH.
 * @throws {Error} If provided executable does not exist, or the chromedriver
 *     cannot be found on the PATH.
 * @constructor
 */
var ServiceBuilder = function(opt_exe) {
  /** @private {string} */
  this.exe_ = opt_exe || io.findInPath(CHROMEDRIVER_EXE, true);
  if (!this.exe_) {
    throw Error(
        'The ChromeDriver could not be found on the current PATH. Please ' +
        'download the latest version of the ChromeDriver from ' +
        'http://chromedriver.storage.googleapis.com/index.html and ensure ' +
        'it can be found on your PATH.');
  }

  if (!fs.existsSync(this.exe_)) {
    throw Error('File does not exist: ' + this.exe_);
  }

  /** @private {!Array.<string>} */
  this.args_ = [];
  this.stdio_ = 'ignore';
};


/** @private {string} */
ServiceBuilder.prototype.path_ = null;

/** @private {number} */
ServiceBuilder.prototype.port_ = 0;


/** @private {(string|!Array.<string|number|!Stream|null|undefined>)} */
ServiceBuilder.prototype.stdio_ = 'ignore';


/** @private {Object.<string, string>} */
ServiceBuilder.prototype.env_ = null;


/**
 * Sets the port to start the ChromeDriver on.
 * @param {number} port The port to use, or 0 for any free port.
 * @return {!ServiceBuilder} A self reference.
 * @throws {Error} If the port is invalid.
 */
ServiceBuilder.prototype.usingPort = function(port) {
  if (port < 0) {
    throw Error('port must be >= 0: ' + port);
  }
  this.port_ = port;
  return this;
};


/**
 * Sets which port adb is listening to. _The ChromeDriver will connect to adb
 * if an {@linkplain Options#androidPackage Android session} is requested, but
 * adb **must** be started beforehand._
 *
 * @param {number} port Which port adb is running on.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.setAdbPort = function(port) {
  this.args_.push('--adb-port=' + port);
  return this;
};


/**
 * Sets the path of the log file the driver should log to. If a log file is
 * not specified, the driver will log to stderr.
 * @param {string} path Path of the log file to use.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.loggingTo = function(path) {
  this.args_.push('--log-path=' + path);
  return this;
};


/**
 * Enables verbose logging.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.enableVerboseLogging = function() {
  this.args_.push('--verbose');
  return this;
};


/**
 * Sets the number of threads the driver should use to manage HTTP requests.
 * By default, the driver will use 4 threads.
 * @param {number} n The number of threads to use.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.setNumHttpThreads = function(n) {
  this.args_.push('--http-threads=' + n);
  return this;
};


/**
 * Sets the base path for WebDriver REST commands (e.g. "/wd/hub").
 * By default, the driver will accept commands relative to "/".
 * @param {string} path The base path to use.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.setUrlBasePath = function(path) {
  this.args_.push('--url-base=' + path);
  this.path_ = path;
  return this;
};


/**
 * Defines the stdio configuration for the driver service. See
 * {@code child_process.spawn} for more information.
 * @param {(string|!Array.<string|number|!Stream|null|undefined>)} config The
 *     configuration to use.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.setStdio = function(config) {
  this.stdio_ = config;
  return this;
};


/**
 * Defines the environment to start the server under. This settings will be
 * inherited by every browser session started by the server.
 * @param {!Object.<string, string>} env The environment to use.
 * @return {!ServiceBuilder} A self reference.
 */
ServiceBuilder.prototype.withEnvironment = function(env) {
  this.env_ = env;
  return this;
};


/**
 * Creates a new DriverService using this instance's current configuration.
 * @return {remote.DriverService} A new driver service using this instance's
 *     current configuration.
 * @throws {Error} If the driver exectuable was not specified and a default
 *     could not be found on the current PATH.
 */
ServiceBuilder.prototype.build = function() {
  var port = this.port_ || portprober.findFreePort();
  var args = this.args_.concat();  // Defensive copy.

  return new remote.DriverService(this.exe_, {
    loopback: true,
    path: this.path_,
    port: port,
    args: webdriver.promise.when(port, function(port) {
      return args.concat('--port=' + port);
    }),
    env: this.env_,
    stdio: this.stdio_
  });
};


/** @type {remote.DriverService} */
var defaultService = null;


/**
 * Sets the default service to use for new ChromeDriver instances.
 * @param {!remote.DriverService} service The service to use.
 * @throws {Error} If the default service is currently running.
 */
function setDefaultService(service) {
  if (defaultService && defaultService.isRunning()) {
    throw Error(
        'The previously configured ChromeDriver service is still running. ' +
        'You must shut it down before you may adjust its configuration.');
  }
  defaultService = service;
}


/**
 * Returns the default ChromeDriver service. If such a service has not been
 * configured, one will be constructed using the default configuration for
 * a ChromeDriver executable found on the system PATH.
 * @return {!remote.DriverService} The default ChromeDriver service.
 */
function getDefaultService() {
  if (!defaultService) {
    defaultService = new ServiceBuilder().build();
  }
  return defaultService;
}


/**
 * @type {string}
 * @const
 */
var OPTIONS_CAPABILITY_KEY = 'chromeOptions';


/**
 * Class for managing ChromeDriver specific options.
 * @constructor
 * @extends {webdriver.Serializable}
 */
var Options = function() {
  webdriver.Serializable.call(this);

  /** @private {!Object} */
  this.options_ = {};

  /** @private {!Array.<(string|!Buffer)>} */
  this.extensions_ = [];

  /** @private {?webdriver.logging.Preferences} */
  this.logPrefs_ = null;

  /** @private {?webdriver.ProxyConfig} */
  this.proxy_ = null;
};
util.inherits(Options, webdriver.Serializable);


/**
 * Extracts the ChromeDriver specific options from the given capabilities
 * object.
 * @param {!webdriver.Capabilities} capabilities The capabilities object.
 * @return {!Options} The ChromeDriver options.
 */
Options.fromCapabilities = function(capabilities) {
  var options = new Options();

  var o = capabilities.get(OPTIONS_CAPABILITY_KEY);
  if (o instanceof Options) {
    options = o;
  } else if (o) {
    options.
        addArguments(o.args || []).
        addExtensions(o.extensions || []).
        detachDriver(o.detach).
        excludeSwitches(o.excludeSwitches || []).
        setChromeBinaryPath(o.binary).
        setChromeLogFile(o.logPath).
        setChromeMinidumpPath(o.minidumpPath).
        setLocalState(o.localState).
        setMobileEmulation(o.mobileEmulation).
        setUserPreferences(o.prefs).
        setPerfLoggingPrefs(o.perfLoggingPrefs);
  }

  if (capabilities.has(webdriver.Capability.PROXY)) {
    options.setProxy(capabilities.get(webdriver.Capability.PROXY));
  }

  if (capabilities.has(webdriver.Capability.LOGGING_PREFS)) {
    options.setLoggingPrefs(
        capabilities.get(webdriver.Capability.LOGGING_PREFS));
  }

  return options;
};


/**
 * Add additional command line arguments to use when launching the Chrome
 * browser.  Each argument may be specified with or without the "--" prefix
 * (e.g. "--foo" and "foo"). Arguments with an associated value should be
 * delimited by an "=": "foo=bar".
 * @param {...(string|!Array.<string>)} var_args The arguments to add.
 * @return {!Options} A self reference.
 */
Options.prototype.addArguments = function(var_args) {
  var args = this.options_.args || [];
  args = args.concat.apply(args, arguments);
  if (args.length) {
    this.options_.args = args;
  }
  return this;
};


/**
 * List of Chrome command line switches to exclude that ChromeDriver by default
 * passes when starting Chrome.  Do not prefix switches with "--".
 *
 * @param {...(string|!Array<string>)} var_args The switches to exclude.
 * @return {!Options} A self reference.
 */
Options.prototype.excludeSwitches = function(var_args) {
  var switches = this.options_.excludeSwitches || [];
  switches = switches.concat.apply(switches, arguments);
  if (switches.length) {
    this.options_.excludeSwitches = switches;
  }
  return this;
};


/**
 * Add additional extensions to install when launching Chrome. Each extension
 * should be specified as the path to the packed CRX file, or a Buffer for an
 * extension.
 * @param {...(string|!Buffer|!Array.<(string|!Buffer)>)} var_args The
 *     extensions to add.
 * @return {!Options} A self reference.
 */
Options.prototype.addExtensions = function(var_args) {
  this.extensions_ = this.extensions_.concat.apply(this.extensions_, arguments);
  return this;
};


/**
 * Sets the path to the Chrome binary to use. On Mac OS X, this path should
 * reference the actual Chrome executable, not just the application binary
 * (e.g. "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").
 *
 * The binary path be absolute or relative to the chromedriver server
 * executable, but it must exist on the machine that will launch Chrome.
 *
 * @param {string} path The path to the Chrome binary to use.
 * @return {!Options} A self reference.
 */
Options.prototype.setChromeBinaryPath = function(path) {
  this.options_.binary = path;
  return this;
};


/**
 * Sets whether to leave the started Chrome browser running if the controlling
 * ChromeDriver service is killed before {@link webdriver.WebDriver#quit()} is
 * called.
 * @param {boolean} detach Whether to leave the browser running if the
 *     chromedriver service is killed before the session.
 * @return {!Options} A self reference.
 */
Options.prototype.detachDriver = function(detach) {
  this.options_.detach = detach;
  return this;
};


/**
 * Sets the user preferences for Chrome's user profile. See the "Preferences"
 * file in Chrome's user data directory for examples.
 * @param {!Object} prefs Dictionary of user preferences to use.
 * @return {!Options} A self reference.
 */
Options.prototype.setUserPreferences = function(prefs) {
  this.options_.prefs = prefs;
  return this;
};


/**
 * Sets the logging preferences for the new session.
 * @param {!webdriver.logging.Preferences} prefs The logging preferences.
 * @return {!Options} A self reference.
 */
Options.prototype.setLoggingPrefs = function(prefs) {
  this.logPrefs_ = prefs;
  return this;
};


/**
 * Sets the performance logging preferences. Options include:
 *
 * - `enableNetwork`: Whether or not to collect events from Network domain.
 * - `enablePage`: Whether or not to collect events from Page domain.
 * - `enableTimeline`: Whether or not to collect events from Timeline domain.
 *     Note: when tracing is enabled, Timeline domain is implicitly disabled,
 *     unless `enableTimeline` is explicitly set to true.
 * - `tracingCategories`: A comma-separated string of Chrome tracing categories
 *     for which trace events should be collected. An unspecified or empty
 *     string disables tracing.
 * - `bufferUsageReportingInterval`: The requested number of milliseconds
 *     between DevTools trace buffer usage events. For example, if 1000, then
 *     once per second, DevTools will report how full the trace buffer is. If a
 *     report indicates the buffer usage is 100%, a warning will be issued.
 *
 * @param {{enableNetwork: boolean,
 *          enablePage: boolean,
 *          enableTimeline: boolean,
 *          tracingCategories: string,
 *          bufferUsageReportingInterval: number}} prefs The performance
 *     logging preferences.
 * @return {!Options} A self reference.
 */
Options.prototype.setPerfLoggingPrefs = function(prefs) {
  this.options_.perfLoggingPrefs = prefs;
  return this;
};


/**
 * Sets preferences for the "Local State" file in Chrome's user data
 * directory.
 * @param {!Object} state Dictionary of local state preferences.
 * @return {!Options} A self reference.
 */
Options.prototype.setLocalState = function(state) {
  this.options_.localState = state;
  return this;
};


/**
 * Sets the name of the activity hosting a Chrome-based Android WebView. This
 * option must be set to connect to an [Android WebView](
 * https://sites.google.com/a/chromium.org/chromedriver/getting-started/getting-started---android)
 *
 * @param {string} name The activity name.
 * @return {!Options} A self reference.
 */
Options.prototype.androidActivity = function(name) {
  this.options_.androidActivity = name;
  return this;
};


/**
 * Sets the device serial number to connect to via ADB. If not specified, the
 * ChromeDriver will select an unused device at random. An error will be
 * returned if all devices already have active sessions.
 *
 * @param {string} serial The device serial number to connect to.
 * @return {!Options} A self reference.
 */
Options.prototype.androidDeviceSerial = function(serial) {
  this.options_.androidDeviceSerial = serial;
  return this;
};


/**
 * Configures the ChromeDriver to launch Chrome on Android via adb. This
 * function is shorthand for
 * {@link #androidPackage options.androidPackage('com.android.chrome')}.
 * @return {!Options} A self reference.
 */
Options.prototype.androidChrome = function() {
  return this.androidPackage('com.android.chrome');
};


/**
 * Sets the package name of the Chrome or WebView app.
 *
 * @param {?string} pkg The package to connect to, or `null` to disable Android
 *     and switch back to using desktop Chrome.
 * @return {!Options} A self reference.
 */
Options.prototype.androidPackage = function(pkg) {
  this.options_.androidPackage = pkg;
  return this;
};


/**
 * Sets the process name of the Activity hosting the WebView (as given by `ps`).
 * If not specified, the process name is assumed to be the same as
 * {@link #androidPackage}.
 *
 * @param {string} processName The main activity name.
 * @return {!Options} A self reference.
 */
Options.prototype.androidProcess = function(processName) {
  this.options_.androidProcess = pkg;
  return this;
};


/**
 * Sets whether to connect to an already-running instead of the specified
 * {@linkplain #androidProcess app} instead of launching the app with a clean
 * data directory.
 *
 * @param {boolean} useRunning Whether to connect to a running instance.
 * @return {!Options} A self reference.
 */
Options.prototype.androidUseRunningApp = function(useRunning) {
  this.options_.androidUseRunningApp = useRunning;
  return this;
};


/**
 * Sets the path to Chrome's log file. This path should exist on the machine
 * that will launch Chrome.
 * @param {string} path Path to the log file to use.
 * @return {!Options} A self reference.
 */
Options.prototype.setChromeLogFile = function(path) {
  this.options_.logPath = path;
  return this;
};


/**
 * Sets the directory to store Chrome minidumps in. This option is only
 * supported when ChromeDriver is running on Linux.
 * @param {string} path The directory path.
 * @return {!Options} A self reference.
 */
Options.prototype.setChromeMinidumpPath = function(path) {
  this.options_.minidumpPath = path;
  return this;
};


/**
 * Configures Chrome to emulate a mobile device. For more information, refer to
 * the ChromeDriver project page on [mobile emulation][em]. Configuration
 * options include:
 *
 * - `deviceName`: The name of a pre-configured [emulated device][devem]
 * - `width`: screen width, in pixels
 * - `height`: screen height, in pixels
 * - `pixelRatio`: screen pixel ratio
 *
 * __Example 1: Using a Pre-configured Device__
 *
 *     var options = new chrome.Options().setMobileEmulation(
 *         {deviceName: 'Google Nexus 5'});
 *
 *     var driver = new chrome.Driver(options);
 *
 * __Example 2: Using Custom Screen Configuration__
 *
 *     var options = new chrome.Options().setMobileEmulation({
 *         width: 360,
 *         height: 640,
 *         pixelRatio: 3.0
 *     });
 *
 *     var driver = new chrome.Driver(options);
 *
 *
 * [em]: https://sites.google.com/a/chromium.org/chromedriver/mobile-emulation
 * [devem]: https://developer.chrome.com/devtools/docs/device-mode
 *
 * @param {?({deviceName: string}|
 *           {width: number, height: number, pixelRatio: number})} config The
 *     mobile emulation configuration, or `null` to disable emulation.
 * @return {!Options} A self reference.
 */
Options.prototype.setMobileEmulation = function(config) {
  this.options_.mobileEmulation = config;
  return this;
};


/**
 * Sets the proxy settings for the new session.
 * @param {webdriver.ProxyConfig} proxy The proxy configuration to use.
 * @return {!Options} A self reference.
 */
Options.prototype.setProxy = function(proxy) {
  this.proxy_ = proxy;
  return this;
};


/**
 * Converts this options instance to a {@link webdriver.Capabilities} object.
 * @param {webdriver.Capabilities=} opt_capabilities The capabilities to merge
 *     these options into, if any.
 * @return {!webdriver.Capabilities} The capabilities.
 */
Options.prototype.toCapabilities = function(opt_capabilities) {
  var capabilities = opt_capabilities || webdriver.Capabilities.chrome();
  capabilities.
      set(webdriver.Capability.PROXY, this.proxy_).
      set(webdriver.Capability.LOGGING_PREFS, this.logPrefs_).
      set(OPTIONS_CAPABILITY_KEY, this);
  return capabilities;
};


/**
 * Converts this instance to its JSON wire protocol representation. Note this
 * function is an implementation not intended for general use.
 * @return {{args: !Array.<string>,
 *           binary: (string|undefined),
 *           detach: boolean,
 *           extensions: !Array.<(string|!webdriver.promise.Promise.<string>)>,
 *           localState: (Object|undefined),
 *           logPath: (string|undefined),
 *           prefs: (Object|undefined)}} The JSON wire protocol representation
 *     of this instance.
 * @override
 */
Options.prototype.serialize = function() {
  var json = {};
  for (var key in this.options_) {
    if (this.options_[key] != null) {
      json[key] = this.options_[key];
    }
  }
  if (this.extensions_.length) {
    json.extensions = this.extensions_.map(function(extension) {
      if (Buffer.isBuffer(extension)) {
        return extension.toString('base64');
      }
      return webdriver.promise.checkedNodeCall(
          fs.readFile, extension, 'base64');
    });
  }
  return json;
};


/**
 * Creates a new WebDriver client for Chrome.
 *
 * @param {(webdriver.Capabilities|Options)=} opt_config The configuration
 *     options.
 * @param {remote.DriverService=} opt_service The session to use; will use
 *     the {@linkplain #getDefaultService default service} by default.
 * @param {webdriver.promise.ControlFlow=} opt_flow The control flow to use, or
 *     {@code null} to use the currently active flow.
 * @constructor
 * @extends {webdriver.WebDriver}
 */
var Driver = function(opt_config, opt_service, opt_flow) {
  var service = opt_service || getDefaultService();
  var executor = executors.createExecutor(service.start());

  var capabilities =
      opt_config instanceof Options ? opt_config.toCapabilities() :
      (opt_config || webdriver.Capabilities.chrome());

  var driver = webdriver.WebDriver.createSession(
      executor, capabilities, opt_flow);

  webdriver.WebDriver.call(
      this, driver.getSession(), executor, driver.controlFlow());
};
util.inherits(Driver, webdriver.WebDriver);


/**
 * This function is a no-op as file detectors are not supported by this
 * implementation.
 * @override
 */
Driver.prototype.setFileDetector = function() {
};


// PUBLIC API


exports.Driver = Driver;
exports.Options = Options;
exports.ServiceBuilder = ServiceBuilder;
exports.getDefaultService = getDefaultService;
exports.setDefaultService = setDefaultService;
