4 /** Environment shortcut. */
7 if (env.TRAVIS_SECURE_ENV_VARS == 'false') {
8 console.log('Skipping Sauce Labs jobs; secure environment variables are unavailable');
12 /** Load Node.js modules. */
13 var EventEmitter = require('events').EventEmitter,
14 http = require('http'),
15 path = require('path'),
17 util = require('util');
19 /** Load other modules. */
20 var _ = require('../lodash.js'),
21 chalk = require('chalk'),
22 ecstatic = require('ecstatic'),
23 request = require('request'),
24 SauceTunnel = require('sauce-tunnel');
26 /** Used for Sauce Labs credentials. */
27 var accessKey = env.SAUCE_ACCESS_KEY,
28 username = env.SAUCE_USERNAME;
30 /** Used as the default maximum number of times to retry a job and tunnel. */
31 var maxJobRetries = 3,
34 /** Used as the static file server middleware. */
35 var mount = ecstatic({
40 /** Used as the list of ports supported by Sauce Connect. */
42 80, 443, 888, 2000, 2001, 2020, 2109, 2222, 2310, 3000, 3001, 3030, 3210,
43 3333, 4000, 4001, 4040, 4321, 4502, 4503, 4567, 5000, 5001, 5050, 5555, 5432,
44 6000, 6001, 6060, 6666, 6543, 7000, 7070, 7774, 7777, 8000, 8001, 8003, 8031,
45 8080, 8081, 8765, 8777, 8888, 9000, 9001, 9080, 9090, 9876, 9877, 9999, 49221,
49 /** Used by `logInline` to clear previously logged messages. */
52 /** Method shortcut. */
53 var push = Array.prototype.push;
55 /** Used to detect error messages. */
56 var reError = /(?:\be|E)rror\b/;
58 /** Used to detect valid job ids. */
59 var reJobId = /^[a-z0-9]{32}$/;
61 /** Used to display the wait throbber. */
62 var throbberDelay = 500,
66 * Used as Sauce Labs config values.
67 * See the [Sauce Labs documentation](https://docs.saucelabs.com/reference/test-configuration/)
70 var advisor = getOption('advisor', false),
71 build = getOption('build', (env.TRAVIS_COMMIT || '').slice(0, 10)),
72 commandTimeout = getOption('commandTimeout', 90),
73 compatMode = getOption('compatMode', null),
74 customData = Function('return {' + getOption('customData', '').replace(/^\{|}$/g, '') + '}')(),
75 deviceOrientation = getOption('deviceOrientation', 'portrait'),
76 framework = getOption('framework', 'qunit'),
77 idleTimeout = getOption('idleTimeout', 60),
78 jobName = getOption('name', 'unit tests'),
79 maxDuration = getOption('maxDuration', 180),
80 port = ports[Math.min(_.sortedIndex(ports, getOption('port', 9001)), ports.length - 1)],
81 publicAccess = getOption('public', true),
82 queueTimeout = getOption('queueTimeout', 240),
83 recordVideo = getOption('recordVideo', true),
84 recordScreenshots = getOption('recordScreenshots', false),
85 runner = getOption('runner', 'test/index.html').replace(/^\W+/, ''),
86 runnerUrl = getOption('runnerUrl', 'http://localhost:' + port + '/' + runner),
87 statusInterval = getOption('statusInterval', 5),
88 tags = getOption('tags', []),
89 throttled = getOption('throttled', 10),
90 tunneled = getOption('tunneled', true),
91 tunnelId = getOption('tunnelId', 'tunnel_' + (env.TRAVIS_JOB_ID || 0)),
92 tunnelTimeout = getOption('tunnelTimeout', 120),
93 videoUploadOnPass = getOption('videoUploadOnPass', false);
95 /** Used to convert Sauce Labs browser identifiers to their formal names. */
96 var browserNameMap = {
97 'googlechrome': 'Chrome',
98 'iehta': 'Internet Explorer',
103 /** List of platforms to load the runner on. */
105 ['Linux', 'android', '5.1'],
106 ['Windows 10', 'chrome', '48'],
107 ['Windows 10', 'chrome', '47'],
108 ['Windows 10', 'firefox', '44'],
109 ['Windows 10', 'firefox', '43'],
110 ['Windows 10', 'microsoftedge', '20.10240'],
111 ['Windows 10', 'internet explorer', '11'],
112 ['Windows 8', 'internet explorer', '10'],
113 ['Windows 7', 'internet explorer', '9'],
114 // ['OS X 10.10', 'ipad', '9.1'],
115 ['OS X 10.11', 'safari', '9'],
116 ['OS X 10.10', 'safari', '8']
119 /** Used to tailor the `platforms` array. */
120 var isAMD = _.includes(tags, 'amd'),
121 isBackbone = _.includes(tags, 'backbone'),
122 isModern = _.includes(tags, 'modern');
124 // The platforms to test IE compatibility modes.
127 ['Windows 10', 'internet explorer', '11'],
128 ['Windows 8', 'internet explorer', '10'],
129 ['Windows 7', 'internet explorer', '9'],
130 ['Windows 7', 'internet explorer', '8']
133 // The platforms for AMD tests.
135 platforms = _.filter(platforms, function(platform) {
136 var browser = browserName(platform[1]),
137 version = +platform[2];
140 case 'Android': return version >= 4.4;
141 case 'Opera': return version >= 10;
146 // The platforms for Backbone tests.
148 platforms = _.filter(platforms, function(platform) {
149 var browser = browserName(platform[1]),
150 version = +platform[2];
153 case 'Firefox': return version >= 4;
154 case 'Internet Explorer': return version >= 7;
155 case 'iPad': return version >= 5;
156 case 'Opera': return version >= 12;
161 // The platforms for modern builds.
163 platforms = _.filter(platforms, function(platform) {
164 var browser = browserName(platform[1]),
165 version = +platform[2];
168 case 'Android': return version >= 4.1;
169 case 'Firefox': return version >= 10;
170 case 'Internet Explorer': return version >= 9;
171 case 'iPad': return version >= 6;
172 case 'Opera': return version >= 12;
173 case 'Safari': return version >= 6;
179 /** Used as the default `Job` options object. */
182 'command-timeout': commandTimeout,
183 'custom-data': customData,
184 'device-orientation': deviceOrientation,
185 'framework': framework,
186 'idle-timeout': idleTimeout,
187 'max-duration': maxDuration,
189 'public': publicAccess,
190 'platforms': platforms,
191 'record-screenshots': recordScreenshots,
192 'record-video': recordVideo,
193 'sauce-advisor': advisor,
196 'video-upload-on-pass': videoUploadOnPass
199 if (publicAccess === true) {
200 jobOptions['public'] = 'public';
203 jobOptions['tunnel-identifier'] = tunnelId;
206 /*----------------------------------------------------------------------------*/
209 * Resolves the formal browser name for a given Sauce Labs browser identifier.
212 * @param {string} identifier The browser identifier.
213 * @returns {string} Returns the formal browser name.
215 function browserName(identifier) {
216 return browserNameMap[identifier] || capitalizeWords(identifier);
220 * Capitalizes the first character of each word in `string`.
223 * @param {string} string The string to augment.
224 * @returns {string} Returns the augmented string.
226 function capitalizeWords(string) {
227 return _.map(string.split(' '), _.capitalize).join(' ');
231 * Gets the value for the given option name. If no value is available the
232 * `defaultValue` is returned.
235 * @param {string} name The name of the option.
236 * @param {*} defaultValue The default option value.
237 * @returns {*} Returns the option value.
239 function getOption(name, defaultValue) {
240 var isArr = _.isArray(defaultValue);
241 return _.reduce(process.argv, function(result, value) {
243 value = optionToArray(name, value);
244 return _.isEmpty(value) ? result : value;
246 value = optionToValue(name, value);
248 return value == null ? result : value;
253 * Checks if `value` is a job ID.
256 * @param {*} value The value to check.
257 * @returns {boolean} Returns `true` if `value` is a job ID, else `false`.
259 function isJobId(value) {
260 return reJobId.test(value);
264 * Writes an inline message to standard output.
267 * @param {string} [text=''] The text to log.
269 function logInline(text) {
270 var blankLine = _.repeat(' ', _.size(prevLine));
271 prevLine = text = _.truncate(text, { 'length': 40 });
272 process.stdout.write(text + blankLine.slice(text.length) + '\r');
276 * Writes the wait throbber to standard output.
280 function logThrobber() {
281 logInline('Please wait' + _.repeat('.', (++waitCount % 3) + 1));
285 * Converts a comma separated option value into an array.
288 * @param {string} name The name of the option to inspect.
289 * @param {string} string The options string.
290 * @returns {Array} Returns the new converted array.
292 function optionToArray(name, string) {
293 return _.compact(_.invokeMap((optionToValue(name, string) || '').split(/, */), 'trim'));
297 * Extracts the option value from an option string.
300 * @param {string} name The name of the option to inspect.
301 * @param {string} string The options string.
302 * @returns {string|undefined} Returns the option value, else `undefined`.
304 function optionToValue(name, string) {
305 var result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'));
307 result = _.result(result, 1);
308 result = result ? _.trim(result) : true;
310 if (result === 'false') {
313 return result || undefined;
316 /*----------------------------------------------------------------------------*/
319 * The `Job#remove` and `Tunnel#stop` callback used by `Jobs#restart`
320 * and `Tunnel#restart` respectively.
324 function onGenericRestart() {
325 this.restarting = false;
326 this.emit('restart');
331 * The `request.put` and `SauceTunnel#stop` callback used by `Jobs#stop`
332 * and `Tunnel#stop` respectively.
335 * @param {Object} [error] The error object.
337 function onGenericStop(error) {
338 this.running = this.stopping = false;
339 this.emit('stop', error);
343 * The `request.del` callback used by `Jobs#remove`.
347 function onJobRemove(error, res, body) {
348 this.id = this.taskId = this.url = null;
349 this.removing = false;
354 * The `Job#remove` callback used by `Jobs#reset`.
358 function onJobReset() {
360 this.failed = this.resetting = false;
361 this._pollerId = this.id = this.result = this.taskId = this.url = null;
366 * The `request.post` callback used by `Jobs#start`.
369 * @param {Object} [error] The error object.
370 * @param {Object} res The response data object.
371 * @param {Object} body The response body JSON object.
373 function onJobStart(error, res, body) {
374 this.starting = false;
379 var statusCode = _.result(res, 'statusCode'),
380 taskId = _.first(_.result(body, 'js tests'));
382 if (error || !taskId || statusCode != 200) {
383 if (this.attempts < this.retries) {
387 var na = 'unavailable',
388 bodyStr = _.isObject(body) ? '\n' + JSON.stringify(body) : na,
389 statusStr = _.isFinite(statusCode) ? statusCode : na;
392 console.error('Failed to start job; status: %s, body: %s', statusStr, bodyStr);
394 console.error(error);
397 this.emit('complete');
401 this.taskId = taskId;
402 this.timestamp = _.now();
408 * The `request.post` callback used by `Job#status`.
411 * @param {Object} [error] The error object.
412 * @param {Object} res The response data object.
413 * @param {Object} body The response body JSON object.
415 function onJobStatus(error, res, body) {
416 this.checking = false;
418 if (!this.running || this.stopping) {
421 var completed = _.result(body, 'completed', false),
422 data = _.first(_.result(body, 'js tests')),
423 elapsed = (_.now() - this.timestamp) / 1000,
424 jobId = _.result(data, 'job_id', null),
425 jobResult = _.result(data, 'result', null),
426 jobStatus = _.result(data, 'status', ''),
427 jobUrl = _.result(data, 'url', null),
428 expired = (elapsed >= queueTimeout && !_.includes(jobStatus, 'in progress')),
429 options = this.options,
430 platform = options.platforms[0];
432 if (_.isObject(jobResult)) {
433 var message = _.result(jobResult, 'message');
435 if (typeof jobResult == 'string') {
440 if (isJobId(jobId)) {
442 this.result = jobResult;
447 this.emit('status', jobStatus);
449 if (!completed && !expired) {
450 this._pollerId = _.delay(_.bind(this.status, this), this.statusInterval * 1000);
453 var description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + capitalizeWords(platform[0]),
454 errored = !jobResult || !jobResult.passed || reError.test(message) || reError.test(jobStatus),
455 failures = _.result(jobResult, 'failed'),
456 label = options.name + ':',
457 tunnel = this.tunnel;
459 if (errored || failures) {
460 if (errored && this.attempts < this.retries) {
464 var details = 'See ' + jobUrl + ' for details.';
469 console.error(label + ' %s ' + chalk.red('failed') + ' %d test' + (failures > 1 ? 's' : '') + '. %s', description, failures, details);
471 else if (tunnel.attempts < tunnel.retries) {
476 if (typeof message == 'undefined') {
477 message = 'Results are unavailable. ' + details;
479 console.error(label, description, chalk.red('failed') + ';', message);
484 console.log(label, description, chalk.green('passed'));
486 this.running = false;
487 this.emit('complete');
491 * The `SauceTunnel#start` callback used by `Tunnel#start`.
494 * @param {boolean} success The connection success indicator.
496 function onTunnelStart(success) {
497 this.starting = false;
499 if (this._timeoutId) {
500 clearTimeout(this._timeoutId);
501 this._timeoutId = null;
504 if (this.attempts < this.retries) {
509 console.error('Failed to open Sauce Connect tunnel');
513 console.log('Sauce Connect tunnel opened');
515 var jobs = this.jobs;
516 push.apply(jobs.queue, jobs.all);
521 console.log('Starting jobs...');
525 /*----------------------------------------------------------------------------*/
528 * The Job constructor.
531 * @param {Object} [properties] The properties to initialize a job with.
533 function Job(properties) {
534 EventEmitter.call(this);
537 _.merge(this, properties);
538 _.defaults(this.options, _.cloneDeep(jobOptions));
541 this.checking = this.failed = this.removing = this.resetting = this.restarting = this.running = this.starting = this.stopping = false;
542 this._pollerId = this.id = this.result = this.taskId = this.url = null;
545 util.inherits(Job, EventEmitter);
551 * @param {Function} callback The function called once the job is removed.
552 * @param {Object} Returns the job instance.
554 Job.prototype.remove = function(callback) {
555 this.once('remove', _.iteratee(callback));
559 this.removing = true;
560 return this.stop(function() {
561 var onRemove = _.bind(onJobRemove, this);
566 request.del(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}')(this), {
567 'auth': { 'user': this.user, 'pass': this.pass }
576 * @param {Function} callback The function called once the job is reset.
577 * @param {Object} Returns the job instance.
579 Job.prototype.reset = function(callback) {
580 this.once('reset', _.iteratee(callback));
581 if (this.resetting) {
584 this.resetting = true;
585 return this.remove(onJobReset);
592 * @param {Function} callback The function called once the job is restarted.
593 * @param {Object} Returns the job instance.
595 Job.prototype.restart = function(callback) {
596 this.once('restart', _.iteratee(callback));
597 if (this.restarting) {
600 this.restarting = true;
602 var options = this.options,
603 platform = options.platforms[0],
604 description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + capitalizeWords(platform[0]),
605 label = options.name + ':';
608 console.log('%s %s restart %d of %d', label, description, ++this.attempts, this.retries);
610 return this.remove(onGenericRestart);
617 * @param {Function} callback The function called once the job is started.
618 * @param {Object} Returns the job instance.
620 Job.prototype.start = function(callback) {
621 this.once('start', _.iteratee(callback));
622 if (this.starting || this.running) {
625 this.starting = true;
626 request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests')(this), {
627 'auth': { 'user': this.user, 'pass': this.pass },
629 }, _.bind(onJobStart, this));
635 * Checks the status of a job.
638 * @param {Function} callback The function called once the status is resolved.
639 * @param {Object} Returns the job instance.
641 Job.prototype.status = function(callback) {
642 this.once('status', _.iteratee(callback));
643 if (this.checking || this.removing || this.resetting || this.restarting || this.starting || this.stopping) {
646 this._pollerId = null;
647 this.checking = true;
648 request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests/status')(this), {
649 'auth': { 'user': this.user, 'pass': this.pass },
650 'json': { 'js tests': [this.taskId] }
651 }, _.bind(onJobStatus, this));
660 * @param {Function} callback The function called once the job is stopped.
661 * @param {Object} Returns the job instance.
663 Job.prototype.stop = function(callback) {
664 this.once('stop', _.iteratee(callback));
668 this.stopping = true;
669 if (this._pollerId) {
670 clearTimeout(this._pollerId);
671 this._pollerId = null;
672 this.checking = false;
674 var onStop = _.bind(onGenericStop, this);
675 if (!this.running || !this.id) {
679 request.put(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}/stop')(this), {
680 'auth': { 'user': this.user, 'pass': this.pass }
686 /*----------------------------------------------------------------------------*/
689 * The Tunnel constructor.
692 * @param {Object} [properties] The properties to initialize the tunnel with.
694 function Tunnel(properties) {
695 EventEmitter.call(this);
697 _.merge(this, properties);
702 var all = _.map(this.platforms, _.bind(function(platform) {
703 return new Job(_.merge({
707 'options': { 'platforms': [platform] }
717 _.invokeMap(all, 'on', 'complete', function() {
718 _.pull(active, this);
720 success = !this.failed;
722 if (++completed == total) {
723 tunnel.stop(_.partial(tunnel.emit, 'complete', success));
729 _.invokeMap(all, 'on', 'restart', function() {
730 if (!_.includes(restarted, this)) {
731 restarted.push(this);
733 // Restart tunnel if all active jobs have restarted.
734 var threshold = Math.min(all.length, _.isFinite(throttled) ? throttled : 3);
735 if (tunnel.attempts < tunnel.retries &&
736 active.length >= threshold && _.isEmpty(_.difference(active, restarted))) {
741 this.on('restart', function() {
744 restarted.length = 0;
747 this._timeoutId = null;
749 this.restarting = this.running = this.starting = this.stopping = false;
750 this.jobs = { 'active': active, 'all': all, 'queue': queue };
751 this.connection = new SauceTunnel(this.user, this.pass, this.id, this.tunneled, ['-P', '0']);
754 util.inherits(Tunnel, EventEmitter);
757 * Restarts the tunnel.
760 * @param {Function} callback The function called once the tunnel is restarted.
762 Tunnel.prototype.restart = function(callback) {
763 this.once('restart', _.iteratee(callback));
764 if (this.restarting) {
767 this.restarting = true;
770 console.log('Tunnel %s: restart %d of %d', this.id, ++this.attempts, this.retries);
772 var jobs = this.jobs,
773 active = jobs.active,
776 var reset = _.after(all.length, _.bind(this.stop, this, onGenericRestart)),
777 stop = _.after(active.length, _.partial(_.invokeMap, all, 'reset', reset));
779 if (_.isEmpty(active)) {
782 if (_.isEmpty(all)) {
785 _.invokeMap(active, 'stop', function() {
786 _.pull(active, this);
790 if (this._timeoutId) {
791 clearTimeout(this._timeoutId);
792 this._timeoutId = null;
801 * @param {Function} callback The function called once the tunnel is started.
802 * @param {Object} Returns the tunnel instance.
804 Tunnel.prototype.start = function(callback) {
805 this.once('start', _.iteratee(callback));
806 if (this.starting || this.running) {
809 this.starting = true;
812 console.log('Opening Sauce Connect tunnel...');
814 var onStart = _.bind(onTunnelStart, this);
816 this._timeoutId = _.delay(onStart, this.timeout * 1000, false);
818 this.connection.start(onStart);
823 * Removes jobs from the queue and starts them.
826 * @param {Object} Returns the tunnel instance.
828 Tunnel.prototype.dequeue = function() {
829 var jobs = this.jobs,
830 active = jobs.active,
832 throttled = this.throttled;
834 while (queue.length && (active.length < throttled)) {
835 active.push(queue.shift().start());
844 * @param {Function} callback The function called once the tunnel is stopped.
845 * @param {Object} Returns the tunnel instance.
847 Tunnel.prototype.stop = function(callback) {
848 this.once('stop', _.iteratee(callback));
852 this.stopping = true;
855 console.log('Shutting down Sauce Connect tunnel...');
857 var jobs = this.jobs,
858 active = jobs.active;
860 var stop = _.after(active.length, _.bind(function() {
861 var onStop = _.bind(onGenericStop, this);
863 this.connection.stop(onStop);
869 jobs.queue.length = 0;
870 if (_.isEmpty(active)) {
873 _.invokeMap(active, 'stop', function() {
874 _.pull(active, this);
878 if (this._timeoutId) {
879 clearTimeout(this._timeoutId);
880 this._timeoutId = null;
885 /*----------------------------------------------------------------------------*/
887 // Cleanup any inline logs when exited via `ctrl+c`.
888 process.on('SIGINT', function() {
893 // Create a web server for the current working directory.
894 http.createServer(function(req, res) {
895 // See http://msdn.microsoft.com/en-us/library/ff955275(v=vs.85).aspx.
896 if (compatMode && path.extname(url.parse(req.url).pathname) == '.html') {
897 res.setHeader('X-UA-Compatible', 'IE=' + compatMode);
902 // Setup Sauce Connect so we can use this server from Sauce Labs.
903 var tunnel = new Tunnel({
907 'job': { 'retries': maxJobRetries, 'statusInterval': statusInterval },
908 'platforms': platforms,
909 'retries': maxTunnelRetries,
910 'throttled': throttled,
911 'tunneled': tunneled,
912 'timeout': tunnelTimeout
915 tunnel.on('complete', function(success) {
916 process.exit(success ? 0 : 1);
921 setInterval(logThrobber, throbberDelay);