Files
lodash/test/saucelabs.js
2014-03-19 19:31:45 -07:00

399 lines
12 KiB
JavaScript

#!/usr/bin/env node
;(function() {
'use strict';
/** Environment shortcut */
var env = process.env;
if (isFinite(env.TRAVIS_PULL_REQUEST)) {
console.error('Testing skipped for pull requests');
process.exit(0);
}
/** Load Node.js modules */
var EventEmitter = require('events').EventEmitter,
http = require('http'),
path = require('path'),
url = require('url');
/** Load other modules */
var _ = require('../lodash.js'),
ecstatic = require('ecstatic'),
request = require('request'),
SauceTunnel = require('sauce-tunnel');
/** Used for Sauce Labs credentials */
var accessKey = env.SAUCE_ACCESS_KEY,
username = env.SAUCE_USERNAME;
/** Used as the maximum number of times to retry a job */
var maxRetries = 3;
/** Used by `logInline` to clear previously logged messages */
var prevLine = '';
/** Used to detect error messages */
var reError = /\berror\b/i;
/** Used to display the wait throbber */
var throbberId,
throbberDelay = 500,
waitCount = -1;
/** Used as Sauce Labs config values */
var advisor = getOption('advisor', true),
build = getOption('build', env.TRAVIS_COMMIT.slice(0, 10)),
compatMode = getOption('compatMode', null),
customData = Function('return {' + getOption('customData', '').replace(/^\{|}$/g, '') + '}')(),
framework = getOption('framework', 'qunit'),
idleTimeout = getOption('idleTimeout', 180),
jobName = getOption('name', 'unit tests'),
maxDuration = getOption('maxDuration', 360),
port = getOption('port', 9001),
publicAccess = getOption('public', true),
recordVideo = getOption('recordVideo', false),
recordScreenshots = getOption('recordScreenshots', false),
runner = getOption('runner', 'test/index.html').replace(/^\W+/, ''),
runnerUrl = getOption('runnerUrl', 'http://localhost:' + port + '/' + runner),
statusInterval = getOption('statusInterval', 5000),
tags = getOption('tag', []),
tunneled = getOption('tunneled', true),
tunnelId = getOption('tunnelId', 'tunnel_' + env.TRAVIS_JOB_NUMBER),
tunnelTimeout = getOption('tunnelTimeout', 10000),
videoUploadOnPass = getOption('videoUploadOnPass', false);
/** List of platforms to load the runner on */
var platforms = [
['Windows 8.1', 'googlechrome', '33'],
['Windows 8.1', 'googlechrome', '32'],
['Windows 8.1', 'firefox', '27'],
['Windows 8.1', 'firefox', '26'],
['Windows 8.1', 'firefox', '20'],
['Windows 8.1', 'firefox', '3.0'],
['Windows 8.1', 'internet explorer', '11'],
['Windows 8', 'internet explorer', '10'],
['Windows 7', 'internet explorer', '9'],
['Windows 7', 'internet explorer', '8'],
['Windows XP', 'internet explorer', '7'],
['Windows XP', 'internet explorer', '6'],
['Windows 7', 'opera', '12'],
['Windows 7', 'opera', '11'],
['OS X 10.9', 'safari', '7'],
['OS X 10.8', 'safari', '6'],
['OS X 10.6', 'safari', '5']
];
/** Used to tailor the `platforms` array */
var runnerQuery = url.parse(runner, true).query,
isBackbone = /\bbackbone\b/i.test(runner),
isMobile = /\bmobile\b/i.test(runnerQuery.build),
isModern = /\bmodern\b/i.test(runnerQuery.build);
// platforms to test IE compat mode
if (compatMode) {
platforms = [
['Windows 8.1', 'internet explorer', '11'],
['Windows 8', 'internet explorer', '10'],
['Windows 7', 'internet explorer', '9'],
['Windows 7', 'internet explorer', '8']
];
}
// platforms for AMD tests
if (_.contains(tags, 'amd')) {
platforms = platforms.filter(function(platform) {
var browser = platform[1],
version = +platform[2];
if (browser == 'opera') {
return version >= 10;
}
return true;
});
}
// platforms for Backbone tests
if (isBackbone) {
platforms = platforms.filter(function(platform) {
var browser = platform[1],
version = +platform[2];
switch (browser) {
case 'firefox': return version >= 4;
case 'opera': return version >= 12;
}
return true;
});
}
// platforms for mobile and modern builds
if (isMobile || isModern) {
platforms = platforms.filter(function(platform) {
var browser = platform[1],
version = +platform[2];
switch (browser) {
case 'firefox': return version >= 10;
case 'internet explorer': return version >= 9;
case 'opera': return version >= 12;
case 'safari': return version >= (isMobile ? 3 : 6);
}
return true;
});
}
/** Used as the default `Job` options object */
var defaultOptions = {
'build': build,
'custom-data': customData,
'framework': framework,
'idle-timeout': idleTimeout,
'max-duration': maxDuration,
'name': jobName,
'public': publicAccess,
'platforms': [],
'record-screenshots': recordScreenshots,
'record-video': recordVideo,
'sauce-advisor': advisor,
'tags': tags,
'url': runnerUrl,
'video-upload-on-pass': videoUploadOnPass
};
if (publicAccess === true) {
defaultOptions['public'] = 'public';
}
if (tunneled) {
defaultOptions.tunnel = 'tunnel-identifier:' + tunnelId;
}
/*--------------------------------------------------------------------------*/
/**
* Gets the value for the given option name. If no value is available the
* `defaultValue` is returned.
*
* @private
* @param {string} name The name of the option.
* @param {*} defaultValue The default option value.
* @returns {*} Returns the option value.
*/
function getOption(name, defaultValue) {
var isArr = _.isArray(defaultValue);
return _.reduce(process.argv, function(result, value) {
if (isArr) {
value = optionToArray(name, value);
return _.isEmpty(value) ? result : value;
}
value = optionToValue(name, value);
return value == null ? result : value;
}, defaultValue);
}
/**
* Writes an inline message to standard output.
*
* @private
* @param {string} text The text to log.
*/
function logInline(text) {
var blankLine = _.repeat(' ', _.size(prevLine));
if (text.length > 40) {
text = text.slice(0, 37) + '...';
}
prevLine = text;
process.stdout.write(text + blankLine.slice(text.length) + '\r');
}
/**
* Writes the wait throbber to standard output.
*
* @private
*/
function logThrobber() {
logInline('Please wait' + _.repeat('.', (++waitCount % 3) + 1));
}
/**
* Converts a comma separated option value into an array.
*
* @private
* @param {string} name The name of the option to inspect.
* @param {string} string The options string.
* @returns {Array} Returns the new converted array.
*/
function optionToArray(name, string) {
return _.compact(_.invoke((optionToValue(name, string) || '').split(/, */), 'trim'));
}
/**
* Extracts the option value from an option string.
*
* @private
* @param {string} name The name of the option to inspect.
* @param {string} string The options string.
* @returns {string|undefined} Returns the option value, else `undefined`.
*/
function optionToValue(name, string) {
var result = (result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'))) && (result[1] ? result[1].trim() : true);
if (result === 'false') {
return false;
}
return result || undefined;
}
/*--------------------------------------------------------------------------*/
function check() {
request.post('https://saucelabs.com/rest/v1/' + this.user + '/js-tests/status', {
'auth': { 'user': this.user, 'pass': this.pass },
'json': { 'js tests': [this.id] }
}, onCheck.bind(this));
}
function onCheck(error, response, body) {
var data = _.result(body, 'js tests', [{ 'status': 'test error' }])[0],
result = data.result,
failures = _.result(result, 'failed'),
statusCode = _.result(response, 'statusCode');
if (error || statusCode != 200 || data.status == 'test error') {
logInline('');
console.error('Failed to check test status on Sauce Labs; status: %d, body:\n%s', statusCode, JSON.stringify(body));
if (error) {
console.error(error);
}
process.exit(4);
}
if (!_.result(body, 'completed')) {
setTimeout(check.bind(this), statusInterval);
return;
}
if (!result || failures || reError.test(result.message)) {
if (this.attempts < maxRetries) {
this.attempts++;
this.run();
return;
}
_.assign(this, data, { 'failed': true });
var details = 'See ' + this.url + ' for details.',
message = _.result(result, 'message', 'no results available. ' + details),
platform = JSON.stringify(this.options.platforms[0]);
logInline('');
if (failures) {
console.error('There was %d failures on %s. %s', failures, platform, details);
} else {
console.error('Testing on %s failed; %s', platform, message);
}
}
this.emit('complete');
}
function onRun(error, response, body) {
var id = _.result(body, 'js tests', [])[0],
statusCode = _.result(response, 'statusCode');
if (error || !id || statusCode != 200) {
console.error('Failed to submit test to Sauce Labs; status: %d, body:\n%s', statusCode, JSON.stringify(body));
if (error) {
console.error(error);
}
process.exit(3);
}
this.id = id;
check.call(this);
}
/*--------------------------------------------------------------------------*/
function Job(options) {
EventEmitter.call(this);
_.merge(this, { 'attempts': 0, 'options': {} }, options);
_.defaults(this.options, _.cloneDeep(defaultOptions));
}
Job.prototype = _.create(EventEmitter.prototype);
Job.prototype.run = function() {
console.log('Starting saucelabs test: %s', JSON.stringify(this.options));
request.post('https://saucelabs.com/rest/v1/' + this.user + '/js-tests', {
'auth': { 'user': this.user, 'pass': this.pass },
'json': this.options
}, onRun.bind(this));
};
/*--------------------------------------------------------------------------*/
/**
* Makes a request for Sauce Labs to start the jobs.
*
* @private
*/
function run(platforms) {
var jobs = _.map(platforms, function(platform) {
return new Job({
'user': username,
'pass': accessKey,
'options': { 'platforms': [platform] }
})
});
var failed = 0,
finishedJobs = 0,
totalJobs = jobs.length;
_.invoke(jobs, 'on', 'complete', function() {
console.log('Test passed on platform: %s', JSON.stringify(this.options.platforms[0]));
if (++finishedJobs == totalJobs) {
console.log('Shutting down Sauce Connect tunnel...');
clearInterval(throbberId);
tunnel.stop(function() {
process.exit(failed);
});
} else {
failed |= this.failed;
}
});
throbberId = setInterval(logThrobber, throbberDelay);
logThrobber();
_.invoke(jobs, 'run');
}
// cleanup any inline logs when exited via `ctrl+c`
process.on('SIGINT', function() {
logInline('');
process.exit();
});
// create a web server for the local dir
var mount = ecstatic({
'cache': false,
'root': process.cwd()
});
http.createServer(function(req, res) {
// see http://msdn.microsoft.com/en-us/library/ff955275(v=vs.85).aspx
if (compatMode && path.extname(url.parse(req.url).pathname) == '.html') {
res.setHeader('X-UA-Compatible', 'IE=' + compatMode);
}
mount(req, res);
}).listen(port);
// set up Sauce Connect so we can use this server from Sauce Labs
var tunnel = new SauceTunnel(username, accessKey, tunnelId, tunneled, tunnelTimeout);
console.log('Opening Sauce Connect tunnel...');
tunnel.start(function(success) {
if (success) {
console.log('Sauce Connect tunnel opened');
run(platforms);
} else {
console.error('Failed to open Sauce Connect tunnel');
process.exit(2);
}
});
}());