remote_index.js

// 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.

'use strict'

const url = require('node:url')

const httpUtil = require('../http/util')
const io = require('../io')
const { exec } = require('../io/exec')
const { Zip } = require('../io/zip')
const cmd = require('../lib/command')
const input = require('../lib/input')
const net = require('../net')
const portprober = require('../net/portprober')
const logging = require('../lib/logging')

const { getJavaPath, formatSpawnArgs } = require('./util')

/**
 * @typedef {(string|!Array<string|number|!stream.Stream|null|undefined>)}
 */
let StdIoOptions // eslint-disable-line

/**
 * @typedef {(string|!IThenable<string>)}
 */
let CommandLineFlag // eslint-disable-line

/**
 * A record object that defines the configuration options for a DriverService
 * instance.
 *
 * @record
 */
function ServiceOptions() {}

/**
 * Whether the service should only be accessed on this host's loopback address.
 *
 * @type {(boolean|undefined)}
 */
ServiceOptions.prototype.loopback

/**
 * The host name to access the server on. If this option is specified, the
 * {@link #loopback} option will be ignored.
 *
 * @type {(string|undefined)}
 */
ServiceOptions.prototype.hostname

/**
 * The port to start the server on (must be > 0). If the port is provided as a
 * promise, the service will wait for the promise to resolve before starting.
 *
 * @type {(number|!IThenable<number>)}
 */
ServiceOptions.prototype.port

/**
 * The arguments to pass to the service. If a promise is provided, the service
 * will wait for it to resolve before starting.
 *
 * @type {!(Array<CommandLineFlag>|IThenable<!Array<CommandLineFlag>>)}
 */
ServiceOptions.prototype.args

/**
 * The base path on the server for the WebDriver wire protocol (e.g. '/wd/hub').
 * Defaults to '/'.
 *
 * @type {(string|undefined|null)}
 */
ServiceOptions.prototype.path

/**
 * The environment variables that should be visible to the server process.
 * Defaults to inheriting the current process's environment.
 *
 * @type {(Object<string, string>|undefined)}
 */
ServiceOptions.prototype.env

/**
 * IO configuration for the spawned server process. For more information, refer
 * to the documentation of `child_process.spawn`.
 *
 * @type {(StdIoOptions|undefined)}
 * @see https://nodejs.org/dist/latest-v4.x/docs/api/child_process.html#child_process_options_stdio
 */
ServiceOptions.prototype.stdio

/**
 * Manages the life and death of a native executable WebDriver server.
 *
 * It is expected that the driver server implements the
 * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol.
 * Furthermore, the managed server should support multiple concurrent sessions,
 * so that this class may be reused for multiple clients.
 */
class DriverService {
  /**
   * @param {string} executable Path to the executable to run.
   * @param {!ServiceOptions} options Configuration options for the service.
   */
  constructor(executable, options) {
    /** @private @const */
    this.log_ = logging.getLogger(`${logging.Type.DRIVER}.DriverService`)
    /** @private {string} */
    this.executable_ = executable

    /** @private {boolean} */
    this.loopbackOnly_ = !!options.loopback

    /** @private {(string|undefined)} */
    this.hostname_ = options.hostname

    /** @private {(number|!IThenable<number>)} */
    this.port_ = options.port

    /**
     * @private {!(Array<CommandLineFlag>|
     *             IThenable<!Array<CommandLineFlag>>)}
     */
    this.args_ = options.args

    /** @private {string} */
    this.path_ = options.path || '/'

    /** @private {!Object<string, string>} */
    this.env_ = options.env || process.env

    /**
     * @private {(string|!Array<string|number|!stream.Stream|null|undefined>)}
     */
    this.stdio_ = options.stdio || 'ignore'

    /**
     * A promise for the managed subprocess, or null if the server has not been
     * started yet. This promise will never be rejected.
     * @private {Promise<!exec.Command>}
     */
    this.command_ = null

    /**
     * Promise that resolves to the server's address or null if the server has
     * not been started. This promise will be rejected if the server terminates
     * before it starts accepting WebDriver requests.
     * @private {Promise<string>}
     */
    this.address_ = null
  }

  getExecutable() {
    return this.executable_
  }

  setExecutable(value) {
    this.executable_ = value
  }

  /**
   * @return {!Promise<string>} A promise that resolves to the server's address.
   * @throws {Error} If the server has not been started.
   */
  address() {
    if (this.address_) {
      return this.address_
    }
    throw Error('Server has not been started.')
  }

  /**
   * Returns whether the underlying process is still running. This does not take
   * into account whether the process is in the process of shutting down.
   * @return {boolean} Whether the underlying service process is running.
   */
  isRunning() {
    return !!this.address_
  }

  /**
   * Starts the server if it is not already running.
   * @param {number=} opt_timeoutMs How long to wait, in milliseconds, for the
   *     server to start accepting requests. Defaults to 30 seconds.
   * @return {!Promise<string>} A promise that will resolve to the server's base
   *     URL when it has started accepting requests. If the timeout expires
   *     before the server has started, the promise will be rejected.
   */
  start(opt_timeoutMs) {
    if (this.address_) {
      return this.address_
    }

    const timeout = opt_timeoutMs || DriverService.DEFAULT_START_TIMEOUT_MS
    const self = this

    let resolveCommand
    this.command_ = new Promise((resolve) => (resolveCommand = resolve))

    this.address_ = new Promise((resolveAddress, rejectAddress) => {
      resolveAddress(
        Promise.resolve(this.port_).then((port) => {
          if (port <= 0) {
            throw Error('Port must be > 0: ' + port)
          }

          return resolveCommandLineFlags(this.args_).then((args) => {
            const command = exec(self.executable_, {
              args: args,
              env: self.env_,
              stdio: self.stdio_,
            })

            resolveCommand(command)

            const earlyTermination = command.result().then(function (result) {
              const error =
                result.code == null
                  ? Error('Server was killed with ' + result.signal)
                  : Error('Server terminated early with status ' + result.code)
              rejectAddress(error)
              self.address_ = null
              self.command_ = null
              throw error
            })

            let hostname = self.hostname_
            if (!hostname) {
              hostname = (!self.loopbackOnly_ && net.getAddress()) || net.getLoopbackAddress()
            }

            const serverUrl = url.format({
              protocol: 'http',
              hostname: hostname,
              port: port + '',
              pathname: self.path_,
            })

            return new Promise((fulfill, reject) => {
              let cancelToken = earlyTermination.catch((e) => reject(Error(e.message)))

              httpUtil.waitForServer(serverUrl, timeout, cancelToken).then(
                (_) => fulfill(serverUrl),
                (err) => {
                  if (err instanceof httpUtil.CancellationError) {
                    fulfill(serverUrl)
                  } else {
                    reject(err)
                  }
                },
              )
            })
          })
        }),
      )
    })

    return this.address_
  }

  /**
   * Stops the service if it is not currently running. This function will kill
   * the server immediately. To synchronize with the active control flow, use
   * {@link #stop()}.
   * @return {!Promise} A promise that will be resolved when the server has been
   *     stopped.
   */
  kill() {
    if (!this.address_ || !this.command_) {
      return Promise.resolve() // Not currently running.
    }
    let cmd = this.command_
    this.address_ = null
    this.command_ = null
    return cmd.then((c) => c.kill('SIGTERM'))
  }
}

/**
 * @param {!(Array<CommandLineFlag>|IThenable<!Array<CommandLineFlag>>)} args
 * @return {!Promise<!Array<string>>}
 */
function resolveCommandLineFlags(args) {
  // Resolve the outer array, then the individual flags.
  return Promise.resolve(args).then(/** !Array<CommandLineFlag> */ (args) => Promise.all(args))
}

/**
 * The default amount of time, in milliseconds, to wait for the server to
 * start.
 * @const {number}
 */
DriverService.DEFAULT_START_TIMEOUT_MS = 30 * 1000

/**
 * Creates {@link DriverService} objects that manage a WebDriver server in a
 * child process.
 */
DriverService.Builder = class {
  /**
   * @param {string} exe Path to the executable to use. This executable must
   *     accept the `--port` flag for defining the port to start the server on.
   * @throws {Error} If the provided executable path does not exist.
   */
  constructor(exe) {
    /** @private @const {string} */
    this.exe_ = exe

    /** @private {!ServiceOptions} */
    this.options_ = {
      args: [],
      port: 0,
      env: null,
      stdio: 'ignore',
    }
  }

  /**
   * Define additional command line arguments to use when starting the server.
   *
   * @param {...CommandLineFlag} var_args The arguments to include.
   * @return {!THIS} A self reference.
   * @this {THIS}
   * @template THIS
   */
  addArguments(...arguments_) {
    this.options_.args = this.options_.args.concat(arguments_)
    return this
  }

  /**
   * Sets the host name to access the server on. If specified, the
   * {@linkplain #setLoopback() loopback} setting will be ignored.
   *
   * @param {string} hostname
   * @return {!DriverService.Builder} A self reference.
   */
  setHostname(hostname) {
    this.options_.hostname = hostname
    return this
  }

  /**
   * Sets whether the service should be accessed at this host's loopback
   * address.
   *
   * @param {boolean} loopback
   * @return {!DriverService.Builder} A self reference.
   */
  setLoopback(loopback) {
    this.options_.loopback = loopback
    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} basePath The base path to use, or `null` to use the
   *     default.
   * @return {!DriverService.Builder} A self reference.
   */
  setPath(basePath) {
    this.options_.path = basePath
    return this
  }

  /**
   * Sets the port to start the server on.
   *
   * @param {number} port The port to use, or 0 for any free port.
   * @return {!DriverService.Builder} A self reference.
   * @throws {Error} If an invalid port is specified.
   */
  setPort(port) {
    if (port < 0) {
      throw Error(`port must be >= 0: ${port}`)
    }
    this.options_.port = port
    return this
  }

  /**
   * Defines the environment to start the server under. This setting will be
   * inherited by every browser session started by the server. By default, the
   * server will inherit the enviroment of the current process.
   *
   * @param {(Map<string, string>|Object<string, string>|null)} env The desired
   *     environment to use, or `null` if the server should inherit the
   *     current environment.
   * @return {!DriverService.Builder} A self reference.
   */
  setEnvironment(env) {
    if (env instanceof Map) {
      let tmp = {}
      env.forEach((value, key) => (tmp[key] = value))
      env = tmp
    }
    this.options_.env = env
    return this
  }

  /**
   * IO configuration for the spawned server process. For more information,
   * refer to the documentation of `child_process.spawn`.
   *
   * @param {StdIoOptions} config The desired IO configuration.
   * @return {!DriverService.Builder} A self reference.
   * @see https://nodejs.org/dist/latest-v4.x/docs/api/child_process.html#child_process_options_stdio
   */
  setStdio(config) {
    this.options_.stdio = config
    return this
  }

  /**
   * Creates a new DriverService using this instance's current configuration.
   *
   * @return {!DriverService} A new driver service.
   */
  build() {
    let port = this.options_.port || portprober.findFreePort()
    let args = Promise.resolve(port).then((port) => {
      return this.options_.args.concat('--port=' + port)
    })

    let options =
      /** @type {!ServiceOptions} */
      (Object.assign({}, this.options_, { args, port }))
    return new DriverService(this.exe_, options)
  }
}

/**
 * Manages the life and death of the
 * <a href="https://www.selenium.dev/downloads/">
 * standalone Selenium server</a>.
 */
class SeleniumServer extends DriverService {
  /**
   * @param {string} jar Path to the Selenium server jar.
   * @param {SeleniumServer.Options=} opt_options Configuration options for the
   *     server.
   * @throws {Error} If the path to the Selenium jar is not specified or if an
   *     invalid port is specified.
   */
  constructor(jar, opt_options) {
    if (!jar) {
      throw Error('Path to the Selenium jar not specified')
    }

    const options = opt_options || {}

    if (options.port < 0) {
      throw Error('Port must be >= 0: ' + options.port)
    }

    let port = options.port || portprober.findFreePort()
    let args = Promise.all([port, options.jvmArgs || [], options.args || []]).then((resolved) => {
      let port = resolved[0]
      let jvmArgs = resolved[1]
      let args = resolved[2]

      const fullArgsList = jvmArgs.concat('-jar', jar, '-port', port).concat(args)

      return formatSpawnArgs(jar, fullArgsList)
    })

    const java = getJavaPath()

    super(java, {
      loopback: options.loopback,
      port: port,
      args: args,
      path: '/wd/hub',
      env: options.env,
      stdio: options.stdio,
    })
  }
}

/**
 * A record object describing configuration options for a {@link SeleniumServer}
 * instance.
 *
 * @record
 */
SeleniumServer.Options = class {
  constructor() {
    /**
     * Whether the server should only be accessible on this host's loopback
     * address.
     *
     * @type {(boolean|undefined)}
     */
    this.loopback

    /**
     * The port to start the server on (must be > 0). If the port is provided as
     * a promise, the service will wait for the promise to resolve before
     * starting.
     *
     * @type {(number|!IThenable<number>)}
     */
    this.port

    /**
     * The arguments to pass to the service. If a promise is provided,
     * the service will wait for it to resolve before starting.
     *
     * @type {!(Array<string>|IThenable<!Array<string>>)}
     */
    this.args

    /**
     * The arguments to pass to the JVM. If a promise is provided,
     * the service will wait for it to resolve before starting.
     *
     * @type {(!Array<string>|!IThenable<!Array<string>>|undefined)}
     */
    this.jvmArgs

    /**
     * The environment variables that should be visible to the server
     * process. Defaults to inheriting the current process's environment.
     *
     * @type {(!Object<string, string>|undefined)}
     */
    this.env

    /**
     * IO configuration for the spawned server process. If unspecified, IO will
     * be ignored.
     *
     * @type {(string|!Array<string|number|!stream.Stream|null|undefined>|
     *         undefined)}
     * @see <https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_options_stdio>
     */
    this.stdio
  }
}

/**
 * A {@link webdriver.FileDetector} that may be used when running
 * against a remote
 * [Selenium server](https://www.selenium.dev/downloads/).
 *
 * When a file path on the local machine running this script is entered with
 * {@link webdriver.WebElement#sendKeys WebElement#sendKeys}, this file detector
 * will transfer the specified file to the Selenium server's host; the sendKeys
 * command will be updated to use the transferred file's path.
 *
 * __Note:__ This class depends on a non-standard command supported on the
 * Java Selenium server. The file detector will fail if used with a server that
 * only supports standard WebDriver commands (such as the ChromeDriver).
 *
 * @final
 */
class FileDetector extends input.FileDetector {
  /**
   * Prepares a `file` for use with the remote browser. If the provided path
   * does not reference a normal file (i.e. it does not exist or is a
   * directory), then the promise returned by this method will be resolved with
   * the original file path. Otherwise, this method will upload the file to the
   * remote server, which will return the file's path on the remote system so
   * it may be referenced in subsequent commands.
   *
   * @override
   */
  handleFile(driver, file) {
    return io.stat(file).then(
      function (stats) {
        if (stats.isDirectory()) {
          return file // Not a valid file, return original input.
        }

        let zip = new Zip()
        return zip
          .addFile(file)
          .then(() => zip.toBuffer())
          .then((buf) => buf.toString('base64'))
          .then((encodedZip) => {
            let command = new cmd.Command(cmd.Name.UPLOAD_FILE).setParameter('file', encodedZip)
            return driver.execute(command)
          })
      },
      function (err) {
        if (err.code === 'ENOENT') {
          return file // Not a file; return original input.
        }
        throw err
      },
    )
  }
}

// PUBLIC API

module.exports = {
  DriverService,
  FileDetector,
  SeleniumServer,
  // Exported for API docs.
  ServiceOptions,
}