82f2b76e25
We now use webpack instead of SystemJS, effectively bundling modules into one file (plus commons chunks) for every entry point. This results in a much smaller extension size (almost half). Furthermore we use yarn/npm even for extension run-time dependencies. This relieves us from manually vendoring and building dependencies. It's also easier to understand for new developers familiar with node.
603 lines
18 KiB
JavaScript
603 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((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.
|
|
}
|
|
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.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.
|
|
}
|
|
|
|
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.
|