UNPKG

selenium-webdriver

Version:

The official WebDriver JavaScript bindings from the Selenium project

622 lines (549 loc) 18.3 kB
// 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, }