/**
* @module digdug/Tunnel
*/
var Evented = require('dojo/Evented');
var pathUtil = require('path');
var Promise = require('dojo/Promise');
var sendRequest = require('dojo/request');
var childProcess = require('child_process');
var urlUtil = require('url');
var util = require('./util');
// TODO: Spawned processes are not getting cleaned up if there is a crash
/**
* Clears an array of remover handles.
*
* @param {Handle[]} handles
* @private
*/
function clearHandles(handles) {
var handle;
while ((handle = handles.pop())) {
handle.remove();
}
}
/**
* Creates a new function that emits an event of type `type` on `target` every time the returned function is called.
*
* @param {module:dojo/Evented} target A target event emitter.
* @param {string} type The type of event to emit.
* @returns {Function} The function to call to trigger an event.
* @private
*/
function proxyEvent(target, type) {
return function (data) {
target.emit(type, data);
};
}
/**
* A Tunnel is a mechanism for connecting to a WebDriver service provider that securely exposes local services for
* testing within the service provider’s network.
*
* @constructor module:digdug/Tunnel
* @param {Object} kwArgs A map of properties that should be set on the new instance.
*/
function Tunnel(kwArgs) {
Evented.apply(this, arguments);
for (var key in kwArgs) {
Object.defineProperty(this, key, Object.getOwnPropertyDescriptor(kwArgs, key));
}
}
var _super = Evented.prototype;
Tunnel.prototype = util.mixin(Object.create(_super), /** @lends module:digdug/Tunnel# */ {
/**
* Part of the tunnel has been downloaded from the server.
*
* @event module:digdug/Tunnel#downloadprogress
* @type {Object}
* @property {number} received The number of bytes received so far.
* @property {number} total The total number of bytes to download.
*/
/**
* A chunk of raw string data output by the tunnel software to stdout.
*
* @event module:digdug/Tunnel#stdout
* @type {string}
*/
/**
* A chunk of raw string data output by the tunnel software to stderr.
*
* @event module:digdug/Tunnel#stderr
* @type {string}
*/
/**
* Information about the status of the tunnel setup process that is suitable for presentation to end-users.
*
* @event module:digdug/Tunnel#status
* @type {string}
*/
constructor: Tunnel,
/**
* The architecture the tunnel will run against. This information is automatically retrieved for the current
* system at runtime.
*
* @type {string}
*/
architecture: process.arch,
/**
* An HTTP authorization string to use when initiating connections to the tunnel. This value of this property is
* defined by Tunnel subclasses.
*
* @type {string}
*/
auth: null,
/**
* The directory where the tunnel software will be extracted. If the directory does not exist, it will be
* created. This value is set by the tunnel subclasses.
*
* @type {string}
*/
directory: null,
/**
* The executable to spawn in order to create a tunnel. This value is set by the tunnel subclasses.
*
* @type {string}
*/
executable: null,
/**
* The host on which a WebDriver client can access the service provided by the tunnel. This may or may not be
* the host where the tunnel application is running.
*
* @type {string}
* @default
*/
hostname: 'localhost',
/**
* Whether or not the tunnel is currently running.
*
* @type {boolean}
* @readonly
*/
isRunning: false,
/**
* Whether or not the tunnel is currently starting up.
*
* @type {boolean}
* @readonly
*/
isStarting: false,
/**
* Whether or not the tunnel is currently stopping.
*
* @type {boolean}
* @readonly
*/
isStopping: false,
/**
* The path that a WebDriver client should use to access the service provided by the tunnel.
*
* @type {string}
* @default
*/
pathname: '/wd/hub/',
/**
* The operating system the tunnel will run on. This information is automatically retrieved for the current
* system at runtime.
*
* @type {string}
*/
platform: process.platform,
/**
* The local port where the WebDriver server should be exposed by the tunnel.
*
* @type {number}
* @default
*/
port: 4444,
/**
* The protocol (e.g., 'http') that a WebDriver client should use to access the service provided by the tunnel.
*
* @type {string}
* @default
*/
protocol: 'http',
/**
* The URL of a proxy server for the tunnel to go through. Only the hostname, port, and auth are used.
*
* @type {string}
*/
proxy: null,
/**
* A unique identifier for the newly created tunnel.
*
* @type {string=}
*/
tunnelId: null,
/**
* The URL where the tunnel software can be downloaded.
*
* @type {string}
*/
url: null,
/**
* Whether or not to tell the tunnel to provide verbose logging output.
*
* @type {boolean}
* @default
*/
verbose: false,
_handles: null,
_process: null,
/**
* The URL that a WebDriver client should used to interact with this service.
*
* @member {string} clientUrl
* @memberOf module:digdug/Tunnel#
* @type {string}
* @readonly
*/
get clientUrl() {
return urlUtil.format(this);
},
/**
* A map of additional capabilities that need to be sent to the provider when a new session is being created.
*
* @member {string} extraCapabilities
* @memberOf module:digdug/Tunnel#
* @type {Object}
* @readonly
*/
get extraCapabilities() {
return {};
},
/**
* Whether or not the tunnel software has already been downloaded.
*
* @member {string} isDownloaded
* @memberOf module:digdug/Tunnel#
* @type {boolean}
* @readonly
*/
get isDownloaded() {
return util.fileExists(pathUtil.join(this.directory, this.executable));
},
/**
* Downloads and extracts the tunnel software if it is not already downloaded.
*
* This method can be extended by implementations to perform any necessary post-processing, such as setting
* appropriate file permissions on the downloaded executable.
*
* @param {boolean} forceDownload Force downloading the software even if it already has been downloaded.
* @returns {Promise.<void>} A promise that resolves once the download and extraction process has completed.
*/
download: function (forceDownload) {
if (!forceDownload && this.isDownloaded) {
return Promise.resolve();
}
return this._downloadFile(this.url, this.proxy);
},
_downloadFile: function (url, proxy, options) {
var self = this;
return new Promise(function (resolve, reject, progress, setCanceler) {
setCanceler(function (reason) {
request && request.cancel(reason);
});
var request = sendRequest(url, { proxy: proxy });
request.then(
function (response) {
resolve(self._postDownloadFile(response, options));
},
function (error) {
if (error.response && error.response.statusCode >= 400) {
error = new Error('Download server returned status code ' + error.response.statusCode);
}
reject(error);
},
function (info) {
self.emit('downloadprogress', util.mixin({}, info, { url: url }));
progress(info);
}
).catch(function (error) {
reject(error);
});
});
},
/**
* Called with the response after a file download has completed
*/
_postDownloadFile: function (response) {
return util.decompress(response.data, this.directory);
},
/**
* Creates the list of command-line arguments to be passed to the spawned tunnel. Implementations should
* override this method to provide the appropriate command-line arguments.
*
* Arguments passed to {@link module:digdug/Tunnel#_makeChild} will be passed as-is to this method.
*
* @protected
* @returns {string[]} A list of command-line arguments.
*/
_makeArgs: function () {
return [];
},
/**
* Creates a newly spawned child process for the tunnel software. Implementations should call this method to
* create the tunnel process.
*
* Arguments passed to this method will be passed as-is to {@link module:digdug/Tunnel#_makeArgs} and
* {@link module:digdug/Tunnel#_makeOptions}.
*
* @protected
* @returns {{ process: module:ChildProcess, deferred: module:dojo/Deferred }}
* An object containing a newly spawned Process and a Deferred that will be resolved once the tunnel has started
* successfully.
*/
_makeChild: function () {
function handleChildExit() {
if (dfd.promise.state === Promise.State.PENDING) {
var message = 'Tunnel failed to start: ' + (errorMessage || ('Exit code: ' + exitCode));
dfd.reject(new Error(message));
}
}
var command = this.executable;
var args = this._makeArgs.apply(this, arguments);
var options = this._makeOptions.apply(this, arguments);
var dfd = new Promise.Deferred(function (reason) {
child.kill('SIGINT');
return new Promise(function (resolve, reject) {
child.once('exit', function () {
reject(reason);
});
});
});
var child = childProcess.spawn(command, args, options);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
// Detect and reject on common errors, but only until the promise is fulfilled, at which point we should
// no longer be managing any events since it means the process has started successfully and is underway
var errorMessage = '';
var exitCode = null;
var stderrClosed = false;
var handles = [
util.on(child, 'error', dfd.reject.bind(dfd)),
util.on(child.stderr, 'data', function (data) {
errorMessage += data;
}),
util.on(child, 'exit', function (code) {
exitCode = code;
if (stderrClosed) {
handleChildExit();
}
}),
// stderr might still have data in buffer at the time the exit event is sent, so we have to store data
// from stderr and the exit code and reject only once stderr closes
util.on(child.stderr, 'close', function () {
stderrClosed = true;
if (exitCode !== null) {
handleChildExit();
}
})
];
dfd.promise.then(function () {
clearHandles(handles);
}).catch(function () {
clearHandles(handles);
});
return {
process: child,
deferred: dfd
};
},
/**
* Creates the set of options to use when spawning the tunnel process. Implementations should override this
* method to provide the appropriate options for the tunnel software.
*
* Arguments passed to {@link module:digdug/Tunnel#_makeChild} will be passed as-is to this method.
*
* @protected
* @returns {Object} A set of options matching those provided to Node.js {@link module:child_process.spawn}.
*/
_makeOptions: function () {
return {
cwd: this.directory,
env: process.env
};
},
/**
* Sends information about a job to the tunnel provider.
*
* @param {string} jobId The job to send data about. This is usually a session ID.
* @param {JobState} data Data to send to the tunnel provider about the job.
* @returns {Promise.<void>} A promise that resolves once the job state request is complete.
*/
sendJobState: function () {
var dfd = new Promise.Deferred();
dfd.reject(new Error('Job state is not supported by this tunnel.'));
return dfd.promise;
},
/**
* Starts the tunnel, automatically downloading dependencies if necessary.
*
* @returns {Promise.<void>} A promise that resolves once the tunnel has been established.
*/
start: function () {
if (this.isRunning) {
throw new Error('Tunnel is already running');
}
else if (this.isStopping) {
throw new Error('Previous tunnel is still terminating');
}
else if (this.isStarting) {
return this._startTask;
}
this.isStarting = true;
var self = this;
this._startTask = this
.download()
.then(function () {
self._handles = [];
return self._start();
})
.then(function (child) {
var childProcess = child.process;
self._process = childProcess;
self._handles.push(
util.on(childProcess.stdout, 'data', proxyEvent(self, 'stdout')),
util.on(childProcess.stderr, 'data', proxyEvent(self, 'stderr')),
util.on(childProcess, 'exit', function () {
self.isStarting = false;
self.isRunning = false;
})
);
return child.deferred.promise;
});
this._startTask.then(
function () {
self._startTask = null;
self.isStarting = false;
self.isRunning = true;
self.emit('status', 'Ready');
},
function (error) {
self._startTask = null;
self.isStarting = false;
self.emit('status', error.name === 'CancelError' ? 'Start cancelled' : 'Failed to start tunnel');
}
);
return this._startTask;
},
/**
* This method provides the implementation that actually starts the tunnel and any other logic for emitting
* events on the Tunnel based on data passed by the tunnel software.
*
* The default implementation that assumes the tunnel is ready for use once the child process has written to
* `stdout` or `stderr`. This method should be reimplemented by other tunnel launchers to implement correct
* launch detection logic.
*
* @protected
* @returns {{ process: module:ChildProcess, deferred: module:dojo/Deferred }}
* An object containing a reference to the child process, and a Deferred that is resolved once the tunnel is
* ready for use. Normally this will be the object returned from a call to `Tunnel#_makeChild`.
*/
_start: function () {
function resolve() {
clearHandles(handles);
dfd.resolve();
}
var childHandle = this._makeChild();
var child = childHandle.process;
var dfd = childHandle.deferred;
var handles = [
util.on(child.stdout, 'data', resolve),
util.on(child.stderr, 'data', resolve),
util.on(child, 'error', function (error) {
clearHandles(handles);
dfd.reject(error);
})
];
return childHandle;
},
/**
* Stops the tunnel.
*
* @returns {Promise.<integer>}
* A promise that resolves to the exit code for the tunnel once it has been terminated.
*/
stop: function () {
if (this.isStopping) {
throw new Error('Tunnel is already terminating');
}
else if (this.isStarting) {
this._startTask.cancel();
return;
}
else if (!this.isRunning) {
throw new Error('Tunnel is not running');
}
this.isRunning = false;
this.isStopping = true;
var self = this;
return this._stop().then(
function (returnValue) {
clearHandles(self._handles);
self._process = self._handles = null;
self.isRunning = self.isStopping = false;
return returnValue;
},
function (error) {
self.isRunning = true;
self.isStopping = false;
throw error;
}
);
},
/**
* This method provides the implementation that actually stops the tunnel.
*
* The default implementation that assumes the tunnel has been closed once the child process has exited. This
* method should be reimplemented by other tunnel launchers to implement correct shutdown logic, if necessary.
*
* @protected
* @returns {Promise.<void>} A promise that resolves once the tunnel has shut down.
*/
_stop: function () {
var dfd = new Promise.Deferred();
var childProcess = this._process;
childProcess.once('exit', function (code) {
dfd.resolve(code);
});
childProcess.kill('SIGINT');
return dfd.promise;
},
/**
* Get a list of environments available on the service.
*
* This method should be overridden and use a specific implementation that returns normalized
* environments from the service. E.g.
*
* {
* browserName: 'firefox',
* version: '12',
* platform: 'windows',
* descriptor: { <original returned environment> }
* }
*
* @returns An object containing the response and helper functions
*/
getEnvironments: function () {
if (!this.environmentUrl) {
return Promise.resolve([]);
}
var self = this;
return sendRequest(this.environmentUrl, {
password: this.accessKey,
user: this.username,
proxy: this.proxy
}).then(function (response) {
if (response.statusCode >= 200 && response.statusCode < 400) {
return JSON.parse(response.data.toString()).reduce(function (environments, environment) {
return environments.concat(self._normalizeEnvironment(environment));
}, []);
}
else {
throw new Error('Server replied with a status of ' + response.statusCode);
}
});
},
/**
* Normalizes a specific Tunnel environment descriptor to a general form. To be overriden by a child implementation.
* @param environment an environment descriptor specific to the Tunnel
* @returns a normalized environment
* @protected
*/
_normalizeEnvironment: function (environment) {
return environment;
}
});
module.exports = Tunnel;