BrowserStackTunnel.js

/**
 * @module digdug/BrowserStackTunnel
 */

var fs = require('fs');
var pathUtil = require('path');
var Promise = require('dojo/Promise');
var request = require('dojo/request');
var Tunnel = require('./Tunnel');
var urlUtil = require('url');
var util = require('./util');

/**
 * A BrowserStack tunnel.
 *
 * @constructor module:digdug/BrowserStackTunnel
 * @extends module:digdug/Tunnel
 */
function BrowserStackTunnel() {
	this.accessKey = process.env.BROWSERSTACK_ACCESS_KEY;
	this.servers = [];
	this.username = process.env.BROWSERSTACK_USERNAME;
	Tunnel.apply(this, arguments);
}

var _super = Tunnel.prototype;

BrowserStackTunnel.prototype = util.mixin(Object.create(_super), /** @lends module:digdug/BrowserStackTunnel# */ {
	constructor: BrowserStackTunnel,

	/**
	 * The BrowserStack access key. This will be initialized with the value of the `BROWSERSTACK_ACCESS_KEY`
	 * environment variable.
	 *
	 * @type {string}
	 * @default the value of the BROWSERSTACK_ACCESS_KEY environment variable
	 */
	accessKey: null,

	/**
	 * Whether or not to start the tunnel with only WebDriver support. Setting this value to `false` is not
	 * supported.
	 *
	 * @type {boolean}
	 * @default
	 */
	automateOnly: true,

	directory: pathUtil.join(__dirname, 'browserstack'),

	hostname: 'hub.browserstack.com',

	/**
	 * If true, any other tunnels running on the account will be killed when the tunnel is started.
	 *
	 * @type {boolean}
	 * @default
	 */
	killOtherTunnels: false,

	port: 443,

	protocol: 'https',

	/**
	 * A list of server URLs that should be proxied by the tunnel. Only the hostname, port, and protocol are used.
	 *
	 * @type {string[]}
	 */
	servers: null,

	/**
	 * Skip verification that the proxied servers are online and responding at the time the tunnel starts.
	 *
	 * @type {boolean}
	 * @default
	 */
	skipServerValidation: true,

	/**
	 * If true, route all traffic via the local machine.
	 *
	 * @type {boolean}
	 * @default
	 */
	forceLocal: false,

	/**
	 * The BrowserStack username. This will be initialized with the value of the `BROWSERSTACK_USERNAME`
	 * environment variable.
	 *
	 * @type {string}
	 * @default the value of the BROWSERSTACK_USERNAME environment variable
	 */
	username: null,

	/**
	 * The URL of a service that provides a list of environments supported by BrowserStack.
	 */
	environmentUrl: 'https://www.browserstack.com/automate/browsers.json',

	get auth() {
		return (this.username || '') + ':' + (this.accessKey || '');
	},

	get executable() {
		return './BrowserStackLocal' + (this.platform === 'win32' ? '.exe' : '');
	},

	get extraCapabilities() {
		var capabilities = {
			'browserstack.local': 'true'
		};

		if (this.tunnelId) {
			capabilities['browserstack.localIdentifier'] = this.tunnelId;
		}

		return capabilities;
	},

	get url() {
		var platform = this.platform;
		var architecture = this.architecture;
		var url = 'https://www.browserstack.com/browserstack-local/BrowserStackLocal-';

		if (platform === 'darwin' && architecture === 'x64') {
			url += platform + '-' + architecture;
		} else if (platform === 'win32') {
			url += platform;
		}
		else if (platform === 'linux' && (architecture === 'ia32' || architecture === 'x64')) {
			url += platform + '-' + architecture;
		}
		else {
			throw new Error(platform + ' on ' + architecture + ' is not supported');
		}

		url += '.zip';
		return url;
	},

	_postDownloadFile: function (response) {
		var self = this;
		return util.decompress(response.data, this.directory).then(function () {
			var executable = pathUtil.join(self.directory, self.executable);
			fs.chmodSync(executable, parseInt('0755', 8));
		});
	},

	_makeArgs: function () {
		var args = [
			this.accessKey,
			this.servers.map(function (server) {
				server = urlUtil.parse(server);
				return [ server.hostname, server.port, server.protocol === 'https:' ? 1 : 0 ].join(',');
			})
		];

		this.automateOnly && args.push('-onlyAutomate');
		this.forceLocal && args.push('-forcelocal');
		this.killOtherTunnels && args.push('-force');
		this.skipServerValidation && args.push('-skipCheck');
		this.tunnelId && args.push('-localIdentifier', this.tunnelId);
		this.verbose && args.push('-v');

		if (this.proxy) {
			var proxy = urlUtil.parse(this.proxy);

			proxy.hostname && args.push('-proxyHost', proxy.hostname);
			proxy.port && args.push('-proxyPort', proxy.port);

			if (proxy.auth) {
				var auth = proxy.auth.split(':');
				args.push('-proxyUser', auth[0], '-proxyPass', auth[1]);
			}
			else {
				proxy.username && args.push('-proxyUser', proxy.username);
				proxy.password && args.push('-proxyPass', proxy.password);
			}
		}

		return args;
	},

	sendJobState: function (jobId, data) {
		var payload = JSON.stringify({
			status: data.status || data.success ? 'completed' : 'error'
		});

		return request.put('https://www.browserstack.com/automate/sessions/' + jobId + '.json', {
			data: payload,
			handleAs: 'text',
			headers: {
				'Content-Length': Buffer.byteLength(payload, 'utf8'),
				'Content-Type': 'application/json'
			},
			password: this.accessKey,
			user: this.username,
			proxy: this.proxy
		}).then(function (response) {
			if (response.statusCode >= 200 && response.statusCode < 300) {
				return true;
			}
			else {
				throw new Error(response.data || 'Server reported ' + response.statusCode + ' with no other data.');
			}
		});
	},

	_start: function () {
		var child = this._makeChild();
		var childProcess = child.process;
		var dfd = child.deferred;
		var self = this;

		var handle = util.on(childProcess.stdout, 'data', function (data) {
			var error = /\s*\*\*\* Error: (.*)$/m.exec(data);
			if (error) {
				handle.remove();
				dfd.reject(new Error('The tunnel reported: ' + error[1]));
			}
			else if (data.indexOf('You can now access your local server(s) in our remote browser') > -1) {
				handle.remove();
				dfd.resolve();
			}
			else {
				var line = data.replace(/^\s+/, '').replace(/\s+$/, '');
				if (
					/^BrowserStackLocal v/.test(line) ||
					/^Connecting to BrowserStack/.test(line) ||
					/^Connected/.test(line)
				) {
					self.emit('status', line);
				}
			}
		});

		return child;
	},

	_stop: function () {
		var dfd = new Promise.Deferred();
		var childProcess = this._process;
		var exited = false;

		childProcess.once('exit', function (code) {
			exited = true;
			dfd.resolve(code);
		});
		childProcess.kill('SIGINT');

		// As of at least version 5.1, BrowserStackLocal spawns a secondary process. This is the one that needs to
		// receive the CTRL-C, but Node doesn't provide an easy way to get the PID of the secondary process, so we'll
		// just wait a few seconds, then kill the process if it hasn't ended cleanly.
		setTimeout(function () {
			if (!exited) {
				childProcess.kill('SIGTERM');
			}
		}, 5000);

		return dfd.promise;
	},

	/**
	 * Attempt to normalize a BrowserStack described environment with the standard Selenium capabilities
	 * 
	 * BrowserStack returns a list of environments that looks like:
	 *
	 * {
	 *     "browser": "opera",
	 *     "os_version": "Lion",
	 *     "browser_version":"12.15",
	 *     "device": null,
	 *     "os": "OS X"
	 * }
	 * 
	 * @param {Object} environment a BrowserStack environment descriptor
	 * @returns a normalized descriptor
	 * @private
	 */
	_normalizeEnvironment: function (environment) {
		var platformMap = {
			Windows: {
				'10': 'WINDOWS',
				'8.1': 'WIN8',
				'8': 'WIN8',
				'7': 'WINDOWS',
				'XP': 'XP'
			},

			'OS X': 'MAC'
		};

		var browserMap = {
			ie: 'internet explorer'
		};

		// Create the BS platform name for a given os + version
		var platform = platformMap[environment.os] || environment.os;
		if (typeof platform === 'object') {
			platform = platform[environment.os_version];
		}

		var browserName = browserMap[environment.browser] || environment.browser;
		var version = environment.browser_version;

		return {
			platform: platform,
			platformName: environment.os,
			platformVersion: environment.os_version,

			browserName: browserName,
			browserVersion: version,
			version: environment.browser_version,

			descriptor: environment,

			intern: {
				platform: platform,
				browserName: browserName,
				version: version
			}
		};
	}
});

module.exports = BrowserStackTunnel;