diff --git a/lib/dalek.js b/lib/dalek.js index c093d32..6020908 100644 --- a/lib/dalek.js +++ b/lib/dalek.js @@ -33,6 +33,7 @@ var Driver = require('./dalek/driver'); var Reporter = require('./dalek/reporter'); var Timer = require('./dalek/timer'); var Config = require('./dalek/config'); +var Host = require('./dalek/host'); /** * Default options @@ -90,7 +91,8 @@ var Dalek = function (opts) { this._setupDriverEmitter(); // check for file option, throw error if none is given - if (!Array.isArray(this.config.get('tests'))) { + // only bypasses if dalek runs in the remote mode + if (!Array.isArray(this.config.get('tests')) && !this.options.remote) { this.reporterEvents.emit('error', 'No test files given!'); this.driverEmitter.emit('killAll'); process.exit(127); @@ -98,6 +100,14 @@ var Dalek = function (opts) { // init the driver instance this._initDriver(); + + // check if dalek runs as a remote browser launcher + if (this.options.remote) { + var host = new Host({reporterEvents: this.reporterEvents, config: this.config}); + host.run({ + port: !isNaN(parseFloat(this.options.remote)) && isFinite(this.options.remote) ? this.options.remote : false + }); + } }; /** @@ -119,6 +129,11 @@ Dalek.prototype = { */ run: function () { + // early return; in case of remote + if (this.options.remote) { + return this; + } + // start the timer to measure the execution time this.timer.start(); diff --git a/lib/dalek/driver.js b/lib/dalek/driver.js index 64afbab..e41e2cd 100644 --- a/lib/dalek/driver.js +++ b/lib/dalek/driver.js @@ -175,14 +175,24 @@ Driver.prototype = { getDefaultBrowserConfiguration: function (browser, browsers) { var browserConfiguration = {configuration: null, module: null}; + // set browser configuration + if (browsers[browser]) { + browserConfiguration.configuration = browsers[browser]; + } + + // try to load `normal` browser modules first, + // if that doesnt work, try canary builds try { - browserConfiguration.module = require('dalek-browser-' + browser); + // check if the browser is a remote instance + // else, try to load the local browser + if (browserConfiguration.configuration && browserConfiguration.configuration.type === 'remote') { + browserConfiguration.module = require('./remote'); + } else { + browserConfiguration.module = require('dalek-browser-' + browser); + } } catch (e) { browserConfiguration.module = require('dalek-browser-' + browser + '-canary'); } - if (browsers[browser]) { - browserConfiguration.configuration = browsers[browser]; - } return browserConfiguration; }, @@ -326,7 +336,15 @@ Driver.prototype = { browsers = browsersRaw[0]; } + // init the browser configuration var browserConfiguration = this.loadBrowserConfiguration(browser, browsers, driverModule); + + // check if we need to inject the browser alias into the browser module + if (browserConfiguration.module.setBrowser) { + browserConfiguration.module.setBrowser(browser); + } + + // init the driver instance var driverInstance = driverModule.create({events: this.driverEmitter, reporter: this.reporterEvents, browser: browser, config: this.config, browserMo: browserConfiguration.module, browserConf: browserConfiguration.configuration}); // couple driver & session status events for the reporter this.coupleReporterEvents(driverName, browser); diff --git a/lib/dalek/host.js b/lib/dalek/host.js new file mode 100644 index 0000000..c4ace75 --- /dev/null +++ b/lib/dalek/host.js @@ -0,0 +1,517 @@ +/*! + * + * Copyright (c) 2013 Sebastian Golasch + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +'use strict'; + +// ext. libs +var http = require('http'); +var os = require('os'); +var Q = require('q'); + +/** + * Sets the configuration options for the + * dalek remote browser executor + * + * @param {options} opts Configuration options + * @constructor + */ + +var Host = function (opts) { + this.reporterEvents = opts.reporterEvents; + this.config = opts.config; +}; + +/** + * Remote Dalek host proxy + * + * @module Dalek + * @class Host + * @part Remote + * @api + */ + +Host.prototype = { + + /** + * Default port that the Dalek remote server is linking against + * + * @property defaultPort + * @type {integer} + * @default 9020 + */ + + defaultPort: 9020, + + /** + * Instance of the local browser + * + * @property bro + * @type {object} + * @default null + */ + + bro: null, + + /** + * Instance of the reporter event emitter + * + * @property reporterEvents + * @type {EventEmitter2} + * @default null + */ + + reporterEvents: null, + + /** + * Instance of the dalek config + * + * @property config + * @type {Dalek.Config} + * @default null + */ + + config: null, + + /** + * Local configuration + * + * @property configuration + * @type {object} + * @default {} + */ + + configuration: {}, + + /** + * Host address of the called webdriver server + * + * @property remoteHost + * @type {string} + * @default null + */ + + remoteHost: null, + + /** + * Path of the webdriver server endpoint + * + * @property remotePath + * @type {string} + * @default null + */ + + remotePath: null, + + /** + * Port of the called webdriver server + * + * @property remotePort + * @type {string} + * @default null + */ + + remotePort: null, + + /** + * Secret that got emitted by the remote instance + * + * @property remoteSecret + * @type {string} + * @default null + */ + + remoteSecret: null, + + /** + * Identifier of the remote client + * + * @property remoteId + * @type {string} + * @default null + */ + + remoteId: null, + + /** + * Secret that is stored in the local instance + * + * @property secret + * @type {string} + * @default null + */ + + secret: null, + + /** + * Incoming message that needs to be proxied + * to the local webdriver server + * + * @property proxyRequest + * @type {http.IncomingMessage} + * @default null + */ + + proxyRequest: null, + + /** + * Starts the remote proxy server, + * prepares the config + * + * @method run + * @param {object} opts Configuration options + * @chainable + */ + + run: function (opts) { + // apply configuration + this.configuration = this.config.get('host') || {}; + this.configuration.host = this.configuration.host ? !this.configuration.port : 'localhost'; + this.secret = this.configuration.secret ? this.configuration.secret : this.secret; + if (!this.configuration.port || opts.port) { + this.configuration.port = opts.port ? opts.port : this.defaultPort; + } + + // start the proxy server// emit the instance ready event + this.server = http.createServer(this._createServer.bind(this)).listen(this.configuration.port, this.reporterEvents.emit.bind(this.reporterEvents, 'report:remote:ready', {ip: this._getLocalIp(), port: this.configuration.port})); + return this; + }, + + /** + * Shutdown the proxy server + * + * @method kill + * @return {object} Promise + */ + + kill: function () { + var deferred = Q.defer(); + this.server.close(deferred.resolve); + return deferred.promise; + }, + + /** + * Launches the local browser + * + * @method _launcher + * @param {object} request Request from the dalek remote caller + * @param {object} response Response to the dalek remote caller + * @private + * @chainable + */ + + _launcher: function (request, response) { + // extract the browser id from the request url + var browser = this._extractBrowser(request.url); + + // load local browser module + this.bro = this._loadBrowserModule(browser, response); + + // launch the local browser + if (this.bro) { + this.bro + .launch({}, this.reporterEvents, this.config) + .then(this._onBrowserLaunch.bind(this, browser, response)); + } + + return this; + }, + + /** + * Shuts the local browser down, + * end the otherwise hanging request + * + * @method _launcher + * @param {object} response Response to the dalek remote caller + * @private + * @chainable + */ + + _killer: function (response) { + if (this.bro) { + this.bro.kill(); + } + response.setHeader('Connection', 'close'); + response.end(); + this.reporterEvents.emit('report:remote:closed', {id: this.remoteId, browser: this.bro.longName}); + return this; + }, + + /** + * Requires the local browser module & returns it + * + * @method _loadBrowserModule + * @param {string} browser Name of the browser to load + * @param {object} response Response to the dalek remote caller + * @return {object} The local browser module + * @private + */ + + _loadBrowserModule: function (browser, response) { + var bro = null; + try { + bro = require('dalek-browser-' + browser); + } catch (e) { + try { + bro = require('dalek-browser-' + browser + '-canary'); + } catch (e) { + response.setHeader('Connection', 'close'); + response.end(JSON.stringify({error: 'The requested browser "' + browser + '" could not be loaded'})); + } + } + + return bro; + }, + + /** + * Stores network data from the local browser instance, + * sends browser specific data to the client + * + * @method _onBrowserLaunch + * @param {string} browser Name of the browser to load + * @param {object} response Response to the dalek remote caller + * @chainable + * @private + */ + + _onBrowserLaunch: function (browser, response) { + this.remoteHost = this.bro.getHost(); + this.remotePort = this.bro.getPort(); + this.remotePath = this.bro.path.replace('/', ''); + this.reporterEvents.emit('report:remote:established', {id: this.remoteId, browser: this.bro.longName}); + response.setHeader('Connection', 'close'); + response.end(JSON.stringify({browser: browser, caps: this.bro.desiredCapabilities, defaults: this.bro.driverDefaults, name: this.bro.longName})); + return this; + }, + + /** + * Dispatches all incoming requests, + * possible endpoints local webdriver server, + * browser launcher, browser shutdown handler + * + * @method _createServer + * @param {object} request Request from the dalek remote caller + * @param {object} response Response to the dalek remote caller + * @private + * @chainable + */ + + _createServer: function (request, response) { + // delegate calls based on url + if (request.url.search('/dalek/launch/') !== -1) { + + // store the remotes ip address + this.remoteId = request.connection.remoteAddress; + + // store the remote secret + if (request.headers['secret-token']) { + this.remoteSecret = request.headers['secret-token']; + } + + // check if the secrets match, then launch browser + // else emit an error + if (this.secret === this.remoteSecret) { + this._launcher(request, response); + } else { + response.setHeader('Connection', 'close'); + response.end(JSON.stringify({error: 'Secrets do not match'})); + } + + } else if (request.url.search('/dalek/kill') !== -1) { + this._killer(response); + } else { + this.proxyRequest = http.request(this._generateProxyRequestOptions(request.headers, request.method, request.url), this._onProxyRequest.bind(this, response, request)); + request.on('data', this._onRequestDataChunk.bind(this)); + request.on('end', this.proxyRequest.end.bind(this.proxyRequest)); + } + + return this; + }, + + /** + * Proxies data from the local webdriver server to the client + * + * @method _onRequestDataChunk + * @param {buffer} chunk Chunk of the incoming request data + * @private + * @chainable + */ + + _onRequestDataChunk: function (chunk) { + this.proxyRequest.write(chunk, 'binary'); + return this; + }, + + /** + * Proxies remote data to the webdriver server + * + * @method _onProxyRequest + * @param {object} request Request from the dalek remote caller + * @param {object} response Response to the dalek remote caller + * @param {object} res Response to the local webdriver server + * @private + * @chainable + */ + + _onProxyRequest: function (response, request, res) { + var chunks = []; + + // deny access if the remote ids (onitial request, webdriver request) do not match + if (this.remoteId !== request.connection.remoteAddress) { + response.setHeader('Connection', 'close'); + response.end(); + return this; + } + + res.on('data', function (chunk) { + chunks.push(chunk+''); + }).on('end', this._onProxyRequestEnd.bind(this, res, response, request, chunks)); + return this; + }, + + /** + * Handles data exchange between the client and the + * local webdriver server + * + * @method _onProxyRequest + * @param {object} request Request from the dalek remote caller + * @param {object} response Response to the dalek remote caller + * @param {object} res Response to the local webdriver server + * @param {array} chunks Array of received data pieces that should be forwarded to the local webdriver server + * @private + * @chainable + */ + + _onProxyRequestEnd: function (res, response, request, chunks) { + var buf = ''; + + // proxy headers for the session request + if (request.url === '/session') { + response.setHeader('Connection', 'close'); + Object.keys(res.headers).forEach(function (key) { + response.setHeader(key, res.headers[key]); + }); + } + + if (chunks.length) { + buf = chunks.join(''); + } + + response.write(buf); + response.end(); + return this; + }, + + /** + * Extracts the browser that should be launched + * from the launch url request + * + * @method _extractBrowser + * @param {string} url Url to parse + * @return {string} Extracted browser + * @private + */ + + _extractBrowser: function (url) { + return url.replace('/dalek/launch/', ''); + }, + + /** + * Generates the request options from the incoming + * request that should then be forwared to the local + * webdriver server + * + * @method _generateProxyRequestOptions + * @param {object} header Header meta data + * @param {string} method HTTP method + * @param {string} url Webriver server endpoint url + * @return {object} Request options + * @private + */ + + _generateProxyRequestOptions: function (headers, method, url) { + var options = { + host: this.remoteHost, + port: this.remotePort, + path: this.remotePath + url, + method: method, + headers: { + 'Content-Type': headers['content-type'], + 'Content-Length': headers['content-length'] + } + }; + + // check if the path is valid, + // else prepend a `root` slash + if (options.path.charAt(0) !== '/') { + options.path = '/' + options.path; + } + + return options; + }, + + /** + * Gets the local ip address + * (should be the IPv4 address where the runner is accessible from) + * + * @method _getLocalIp + * @return {string} Local IP address + * @private + */ + + _getLocalIp: function () { + var ifaces = os.networkInterfaces(); + var address = [null]; + for (var dev in ifaces) { + var alias = [0]; + ifaces[dev].forEach(this._grepIp.bind(this, alias, address)); + } + + return address[0]; + }, + + /** + * Tries to find the local IP address + * + * @method _grepIp + * @param + * @param + * @param + * @chainable + * @private + */ + + _grepIp: function (alias, address, details) { + if (details.family === 'IPv4') { + if (details.address !== '127.0.0.1') { + address[0] = details.address; + } + ++alias[0]; + } + + return this; + } + +}; + +module.exports = Host; \ No newline at end of file diff --git a/lib/dalek/remote.js b/lib/dalek/remote.js new file mode 100644 index 0000000..c90b4bd --- /dev/null +++ b/lib/dalek/remote.js @@ -0,0 +1,319 @@ +/*! + * + * Copyright (c) 2013 Sebastian Golasch + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +'use strict'; + +// ext. libs +var Q = require('q'); +var http = require('http'); + +/** + * Mimics a real browser that runs in a remote dalek instance + * + * @module Remote + * @class Remote + * @namespace Dalek + */ + +var Remote = { + + /** + * Remote webdriver path + * + * @property path + * @type {string} + * @default '' + */ + + path: '', + + /** + * Remote port + * + * @property port + * @type {integer} + * @default 9020 + */ + + port: 9020, + + /** + * Remote host + * + * @property host + * @type {string} + * @default '' + */ + + host: '', + + /** + * Url (with placeholders) to launch browsers on the remote instance + * + * @property defaultLaunchUrl + * @type {string} + * @default http://{{host}}:{{port}}/dalek/launch/{{browser}} + */ + + defaultLaunchUrl: 'http://{{host}}:{{port}}/dalek/launch/{{browser}}', + + /** + * Url (with placeholders) to kill browsers on the remote instance + * + * @property defaultKillUrl + * @type {string} + * @default http://{{host}}:{{port}}/dalek/kill + */ + + defaultKillUrl: 'http://{{host}}:{{port}}/dalek/kill', + + /** + * Url to start a specific remote browser session + * + * @property launchUrl + * @type {string} + * @default '' + */ + + launchUrl: '', + + /** + * Url to kill a specific remote browser session + * + * @property killUrl + * @type {string} + * @default '' + */ + + killUrl: '', + + /** + * Internal config name of the browser to start remotly + * + * @property browser + * @type {string} + * @default ' + */ + + browser: '', + + /** + * Remote browser alias to start a browser + * (browser.name or browser.actAs or user input browser alias) + * + * @property browserAlias + * @type {string} + * @default ' + */ + + browserAlias: '', + + /** + * Driver defaults + * + * @property driverDefaults + * @type {object} + */ + + driverDefaults: {}, + + /** + * Request secret or false when unsecure + * + * @param secret + * @type {string|bool} + * @default false + */ + + secret: false, + + /** + * Stores & validates the incoming browser config + * + * @method launch + * @param {object} configuration Browser configuration + * @param {EventEmitter2} events EventEmitter (Reporter Emitter instance) + * @param {Dalek.Internal.Config} config Dalek configuration class + * @return {object} Browser promise + */ + + launch: function (configuration, events, config) { + var deferred = Q.defer(); + + // store injected configuration/log event handlers + this.reporterEvents = events; + this.configuration = configuration; + this.config = config; + + // load configs + this._loadConfigs(configuration, config); + + // fire up the remote browser + var request = http.request(this.launchUrl, this._afterRemoteBrowserLaunched.bind(this, deferred)); + + // set secret header if available + if (this.secret) { + request.setHeader('secret-token', this.secret); + } + + // fire the request + request.end(); + + return deferred.promise; + }, + + /** + * Kills the remote browser + * + * @method kill + * @return {object} Promise + */ + + kill: function () { + http.request(this.killUrl).end(); + return this; + }, + + /** + * Injects the browser name + * + * @method setBrowser + * @param {string} browser Browser to launch + * @chainable + */ + + setBrowser: function (browser) { + this.browser = browser; + + // generate kill & launch url + this.launchUrl = this._replaceUrlPlaceholder(this.defaultLaunchUrl); + this.killUrl = this._replaceUrlPlaceholder(this.defaultKillUrl); + return this; + }, + + /** + * Listens on the response of the initial browser launch call + * and collects the response data, fires the _handshakeFinished call + * after the response ended + * + * @method _afterRemoteBrowserLaunched + * @param {object} deferred Promise + * @param {object} response Browser launch response object + * @chainable + * @private + */ + + _afterRemoteBrowserLaunched: function (deferred, response) { + // collect remote browser information and + // start the test execution after the handshake finished + var data = []; + response.on('data', function (chunk) { + data.push(chunk+''); + }).on('end', this._handshakeFinished.bind(this, deferred, data)); + return this; + }, + + /** + * Parses the response data of the initial browser handshake, + * sets the longName, desiredCapabilities & driverDefaults, + * emits the browser data (can be used by reporters & drivers) + * + * @method _handshakeFinished + * @param {object} deferred Promise + * @param {array} data Remote browser data (longName, desiredCapabilities, driverDefaults) + * @chainable + * @private + */ + + _handshakeFinished: function (deferred, data) { + var br = JSON.parse(data); + + // check if an error happened + if (!!br.error) { + this.reporterEvents.emit('error', br.error); + return this; + } + + // update the desired capabilities & browser defaults in the remote instance + this.longName = br.name; + this.desiredCapabilities = br.caps; + this.driverDefaults = br.defaults; + + // update the desired capabilities & browser defaults in the driver instance + this.reporterEvents.emit('browser:notify:data:' + this.browser, {desiredCapabilities: this.desiredCapabilities, defaults: this.driverDefaults}); + + deferred.resolve(); + return this; + }, + + /** + * Sets the host & port of the remote instance, + * extracts the remote browser to call, + * generates the launch & kill objects for this session + * + * @method _loadConfigs + * @param {object} configuration Browser session configuration + * @param {object} config Dalek configuration data + * @chainable + * @private + */ + + _loadConfigs: function (configuration, config) { + // set host & port + this.host = configuration.host ? configuration.host : this.host; + this.port = configuration.port ? configuration.port : this.port; + + // get the browser alias & secret + this.browserAlias = this.browser; + var browserConfig = config.get('browsers'); + if (browserConfig && browserConfig[0] && browserConfig[0][this.browser]) { + this.browserAlias = browserConfig[0][this.browser].actAs ? browserConfig[0][this.browser].actAs : this.browserAlias; + this.browserAlias = browserConfig[0][this.browser].name ? browserConfig[0][this.browser].name : this.browserAlias; + this.secret = browserConfig[0][this.browser].secret ? browserConfig[0][this.browser].secret : this.secret; + } + + // generate kill & launch url + this.launchUrl = this._replaceUrlPlaceholder(this.defaultLaunchUrl); + this.killUrl = this._replaceUrlPlaceholder(this.defaultKillUrl); + + return this; + }, + + /** + * Replaces {{host}}, {{port}} & {{browser}} placeholders + * in the given url with data from this.host, this.port & this.browserAlias + * + * @method _replaceUrlPlaceholder + * @param {string} url Url with placeholder + * @return {string} Url with replaced placeholders + * @private + */ + + _replaceUrlPlaceholder: function (url) { + url = url.replace('{{port}}', this.port).replace('{{host}}', this.host).replace('{{browser}}', this.browserAlias); + return url; + } +}; + +module.exports = Remote; \ No newline at end of file