blob: 04f0e13f480c8ccb1279dd16b9afa7bfeb096093 [file] [log] [blame]
// 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 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 {Zip} = require('../io/zip');
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<string|number|!stream.Stream|null|undefined>)}
*/
var StdIoOptions;
/**
* @typedef {(string|!IThenable<string>)}
*/
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<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 {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;
}
/**
* @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_;
}
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((fulfill, reject) => {
let cancelToken =
earlyTermination.catch(e => reject(Error(e.message)));
httpUtil.waitForServer(serverUrl, timeout, cancelToken)
.then(_ => fulfill(serverUrl), err => {
if (err instanceof promise.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'));
}
/**
* Schedules a task in the current control flow to stop the server if it is
* currently running.
* @return {!promise.Thenable} A promise that will be resolved when
* the server has been stopped.
*/
stop() {
return promise.controlFlow().execute(this.kill.bind(this));
}
}
/**
* @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) {
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<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="http://selenium-release.storage.googleapis.com/index.html">
* 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');
}
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);
});
let java = 'java';
if (process.env['JAVA_HOME']) {
java = path.join(process.env['JAVA_HOME'], 'bin/java');
}
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<number>),
* args: !(Array<string>|promise.Promise<!Array<string>>),
* jvmArgs: (!Array<string>|
* !promise.Promise<!Array<string>>|
* undefined),
* env: (!Object<string, string>|undefined),
* stdio: (string|!Array<string|number|!stream.Stream|null|undefined>|
* 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.
}
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.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.