// 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 AdmZip = require('adm-zip'); const fs = require('fs'); const path = require('path'); const url = require('url'); const util = require('util'); const httpUtil = require('../http/util'); const io = require('../io'); const exec = require('../io/exec'); const cmd = require('../lib/command'); const input = require('../lib/input'); const promise = require('../lib/promise'); const webdriver = require('../lib/webdriver'); const net = require('../net'); const portprober = require('../net/portprober'); /** * @typedef {(string|!Array)} */ var StdIoOptions; /** * @typedef {(string|!IThenable)} */ var CommandLineFlag; /** * 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)} */ 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|IThenable>)} */ 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|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 {string} */ this.executable_ = executable; /** @private {boolean} */ this.loopbackOnly_ = !!options.loopback; /** @private {(string|undefined)} */ this.hostname_ = options.hostname; /** @private {(number|!IThenable)} */ this.port_ = options.port; /** * @private {!(Array| * IThenable>)} */ this.args_ = options.args; /** @private {string} */ this.path_ = options.path || '/'; /** @private {!Object} */ this.env_ = options.env || process.env; /** * @private {(string|!Array)} */ 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} */ 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} */ this.address_ = null; } /** * @return {!Promise} 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} 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_; } var timeout = opt_timeoutMs || DriverService.DEFAULT_START_TIMEOUT_MS; var 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 => { var command = exec(self.executable_, { args: args, env: self.env_, stdio: self.stdio_ }); resolveCommand(command); var earlyTermination = command.result().then(function(result) { var 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; }); var hostname = self.hostname_; if (!hostname) { hostname = !self.loopbackOnly_ && net.getAddress() || net.getLoopbackAddress(); } var serverUrl = url.format({ protocol: 'http', hostname: hostname, port: port + '', pathname: self.path_ }); return new Promise(function(fulfill, reject) { var ready = httpUtil.waitForServer(serverUrl, timeout) .then(fulfill, reject); earlyTermination.catch(function(e) { ready.cancel(/** @type {Error} */(e)); reject(Error(e.message)); }); }).then(function() { return serverUrl; }); }); })); }); 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. } return this.command_.then(function(command) { command.kill('SIGTERM'); }); } /** * Schedules a task in the current control flow to stop the server if it is * currently running. * @return {!promise.Promise} A promise that will be resolved when * the server has been stopped. */ stop() { return promise.controlFlow().execute(this.kill.bind(this)); } } /** * @param {!(Array|IThenable>)} args * @return {!Promise>} */ function resolveCommandLineFlags(args) { return Promise.resolve(args) // Resolve the outer array. .then(args => Promise.all(args)); // Then resolve the individual flags. } /** * 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) { if (!fs.existsSync(exe)) { throw Error(`The specified executable path does not exist: ${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(var_args) { let args = Array.prototype.slice.call(arguments, 0); this.options_.args = this.options_.args.concat(args); 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|Object|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 * * standalone Selenium server. */ 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'); } var 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]; return jvmArgs.concat('-jar', jar, '-port', port).concat(args); }); super('java', { loopback: options.loopback, port: port, args: args, path: '/wd/hub', env: options.env, stdio: options.stdio }); } } /** * Options for the Selenium server: * * - `loopback` - Whether the server should only be accessed on this host's * loopback address. * - `port` - 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. * - `args` - The arguments to pass to the service. If a promise is provided, * the service will wait for it to resolve before starting. * - `jvmArgs` - The arguments to pass to the JVM. If a promise is provided, * the service will wait for it to resolve before starting. * - `env` - The environment variables that should be visible to the server * process. Defaults to inheriting the current process's environment. * - `stdio` - IO configuration for the spawned server process. For more * information, refer to the documentation of `child_process.spawn`. * * @typedef {{ * loopback: (boolean|undefined), * port: (number|!promise.Promise), * args: !(Array|promise.Promise>), * jvmArgs: (!Array| * !promise.Promise>| * undefined), * env: (!Object|undefined), * stdio: (string|!Array| * undefined) * }} */ SeleniumServer.Options; /** * A {@link webdriver.FileDetector} that may be used when running * against a remote * [Selenium server](http://selenium-release.storage.googleapis.com/index.html). * * 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 transfered 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. } var zip = new AdmZip(); zip.addLocalFile(file); // Stored compression, see https://en.wikipedia.org/wiki/Zip_(file_format) zip.getEntries()[0].header.method = 0; var command = new cmd.Command(cmd.Name.UPLOAD_FILE) .setParameter('file', zip.toBuffer().toString('base64')); return driver.schedule(command, 'remote.FileDetector.handleFile(' + file + ')'); }, function(err) { if (err.code === 'ENOENT') { return file; // Not a file; return original input. } throw err; }); } } // PUBLIC API exports.DriverService = DriverService; exports.FileDetector = FileDetector; exports.SeleniumServer = SeleniumServer; exports.ServiceOptions = ServiceOptions; // Exported for API docs.