594 lines
18 KiB
JavaScript
594 lines
18 KiB
JavaScript
// 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<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(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<CommandLineFlag>|IThenable<!Array<CommandLineFlag>>)} args
|
|
* @return {!Promise<!Array<string>>}
|
|
*/
|
|
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<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);
|
|
});
|
|
|
|
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.
|
|
}
|
|
|
|
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.
|