4 /** Environment shortcut. */
7 /** Load Node.js modules. */
8 var EventEmitter = require('events').EventEmitter,
9 http = require('http'),
10 path = require('path'),
12 util = require('util');
14 /** Load other modules. */
15 var _ = require('../lodash.js'),
16 chalk = require('chalk'),
17 ecstatic = require('ecstatic'),
18 request = require('request'),
19 SauceTunnel = require('sauce-tunnel');
21 /** Used for Sauce Labs credentials. */
22 var accessKey = env.SAUCE_ACCESS_KEY,
23 username = env.SAUCE_USERNAME;
25 /** Used as the default maximum number of times to retry a job and tunnel. */
26 var maxJobRetries = 3,
29 /** Used as the static file server middleware. */
30 var mount = ecstatic({
35 /** Used as the list of ports supported by Sauce Connect. */
37 80, 443, 888, 2000, 2001, 2020, 2109, 2222, 2310, 3000, 3001, 3030, 3210,
38 3333, 4000, 4001, 4040, 4321, 4502, 4503, 4567, 5000, 5001, 5050, 5555, 5432,
39 6000, 6001, 6060, 6666, 6543, 7000, 7070, 7774, 7777, 8000, 8001, 8003, 8031,
40 8080, 8081, 8765, 8777, 8888, 9000, 9001, 9080, 9090, 9876, 9877, 9999, 49221,
44 /** Used by `logInline` to clear previously logged messages. */
47 /** Method shortcut. */
48 var push = Array.prototype.push;
50 /** Used to detect error messages. */
51 var reError = /(?:\be|E)rror\b/;
53 /** Used to detect valid job ids. */
54 var reJobId = /^[a-z0-9]{32}$/;
56 /** Used to display the wait throbber. */
57 var throbberDelay = 500,
61 * Used as Sauce Labs config values.
62 * See the [Sauce Labs documentation](https://docs.saucelabs.com/reference/test-configuration/)
65 var advisor = getOption('advisor', false),
66 build = getOption('build', (env.TRAVIS_COMMIT || '').slice(0, 10)),
67 commandTimeout = getOption('commandTimeout', 90),
68 compatMode = getOption('compatMode', null),
69 customData = Function('return {' + getOption('customData', '').replace(/^\{|}$/g, '') + '}')(),
70 deviceOrientation = getOption('deviceOrientation', 'portrait'),
71 framework = getOption('framework', 'qunit'),
72 idleTimeout = getOption('idleTimeout', 60),
73 jobName = getOption('name', 'unit tests'),
74 maxDuration = getOption('maxDuration', 180),
75 port = ports[Math.min(_.sortedIndex(ports, getOption('port', 9001)), ports.length - 1)],
76 publicAccess = getOption('public', true),
77 queueTimeout = getOption('queueTimeout', 240),
78 recordVideo = getOption('recordVideo', true),
79 recordScreenshots = getOption('recordScreenshots', false),
80 runner = getOption('runner', 'test/index.html').replace(/^\W+/, ''),
81 runnerUrl = getOption('runnerUrl', 'http://localhost:' + port + '/' + runner),
82 statusInterval = getOption('statusInterval', 5),
83 tags = getOption('tags', []),
84 throttled = getOption('throttled', 10),
85 tunneled = getOption('tunneled', true),
86 tunnelId = getOption('tunnelId', 'tunnel_' + (env.TRAVIS_JOB_ID || 0)),
87 tunnelTimeout = getOption('tunnelTimeout', 120),
88 videoUploadOnPass = getOption('videoUploadOnPass', false);
90 /** Used to convert Sauce Labs browser identifiers to their formal names. */
91 var browserNameMap = {
92 'googlechrome': 'Chrome',
93 'iehta': 'Internet Explorer',
96 'microsoftedge': 'Edge'
99 /** List of platforms to load the runner on. */
101 ['Linux', 'android', '5.1'],
102 ['Windows 10', 'chrome', '54'],
103 ['Windows 10', 'chrome', '53'],
104 ['Windows 10', 'firefox', '50'],
105 ['Windows 10', 'firefox', '49'],
106 ['Windows 10', 'microsoftedge', '14'],
107 ['Windows 10', 'internet explorer', '11'],
108 ['Windows 8', 'internet explorer', '10'],
109 ['Windows 7', 'internet explorer', '9'],
110 ['macOS 10.12', 'safari', '10'],
111 ['OS X 10.11', 'safari', '9']
114 /** Used to tailor the `platforms` array. */
115 var isAMD = _.includes(tags, 'amd'),
116 isBackbone = _.includes(tags, 'backbone'),
117 isModern = _.includes(tags, 'modern');
119 // The platforms to test IE compatibility modes.
122 ['Windows 10', 'internet explorer', '11'],
123 ['Windows 8', 'internet explorer', '10'],
124 ['Windows 7', 'internet explorer', '9'],
125 ['Windows 7', 'internet explorer', '8']
128 // The platforms for AMD tests.
130 platforms = _.filter(platforms, function(platform) {
131 var browser = browserName(platform[1]),
132 version = +platform[2];
135 case 'Android': return version >= 4.4;
136 case 'Opera': return version >= 10;
141 // The platforms for Backbone tests.
143 platforms = _.filter(platforms, function(platform) {
144 var browser = browserName(platform[1]),
145 version = +platform[2];
148 case 'Firefox': return version >= 4;
149 case 'Internet Explorer': return version >= 7;
150 case 'iPad': return version >= 5;
151 case 'Opera': return version >= 12;
156 // The platforms for modern builds.
158 platforms = _.filter(platforms, function(platform) {
159 var browser = browserName(platform[1]),
160 version = +platform[2];
163 case 'Android': return version >= 4.1;
164 case 'Firefox': return version >= 10;
165 case 'Internet Explorer': return version >= 9;
166 case 'iPad': return version >= 6;
167 case 'Opera': return version >= 12;
168 case 'Safari': return version >= 6;
174 /** Used as the default `Job` options object. */
177 'command-timeout': commandTimeout,
178 'custom-data': customData,
179 'device-orientation': deviceOrientation,
180 'framework': framework,
181 'idle-timeout': idleTimeout,
182 'max-duration': maxDuration,
184 'public': publicAccess,
185 'platforms': platforms,
186 'record-screenshots': recordScreenshots,
187 'record-video': recordVideo,
188 'sauce-advisor': advisor,
191 'video-upload-on-pass': videoUploadOnPass
194 if (publicAccess === true) {
195 jobOptions['public'] = 'public';
198 jobOptions['tunnel-identifier'] = tunnelId;
201 /*----------------------------------------------------------------------------*/
204 * Resolves the formal browser name for a given Sauce Labs browser identifier.
207 * @param {string} identifier The browser identifier.
208 * @returns {string} Returns the formal browser name.
210 function browserName(identifier) {
211 return browserNameMap[identifier] || _.startCase(identifier);
215 * Gets the value for the given option name. If no value is available the
216 * `defaultValue` is returned.
219 * @param {string} name The name of the option.
220 * @param {*} defaultValue The default option value.
221 * @returns {*} Returns the option value.
223 function getOption(name, defaultValue) {
224 var isArr = _.isArray(defaultValue);
225 return _.reduce(process.argv, function(result, value) {
227 value = optionToArray(name, value);
228 return _.isEmpty(value) ? result : value;
230 value = optionToValue(name, value);
232 return value == null ? result : value;
237 * Checks if `value` is a job ID.
240 * @param {*} value The value to check.
241 * @returns {boolean} Returns `true` if `value` is a job ID, else `false`.
243 function isJobId(value) {
244 return reJobId.test(value);
248 * Writes an inline message to standard output.
251 * @param {string} [text=''] The text to log.
253 function logInline(text) {
254 var blankLine = _.repeat(' ', _.size(prevLine));
255 prevLine = text = _.truncate(text, { 'length': 40 });
256 process.stdout.write(text + blankLine.slice(text.length) + '\r');
260 * Writes the wait throbber to standard output.
264 function logThrobber() {
265 logInline('Please wait' + _.repeat('.', (++waitCount % 3) + 1));
269 * Converts a comma separated option value into an array.
272 * @param {string} name The name of the option to inspect.
273 * @param {string} string The options string.
274 * @returns {Array} Returns the new converted array.
276 function optionToArray(name, string) {
277 return _.compact(_.invokeMap((optionToValue(name, string) || '').split(/, */), 'trim'));
281 * Extracts the option value from an option string.
284 * @param {string} name The name of the option to inspect.
285 * @param {string} string The options string.
286 * @returns {string|undefined} Returns the option value, else `undefined`.
288 function optionToValue(name, string) {
289 var result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'));
291 result = _.get(result, 1);
292 result = result ? _.trim(result) : true;
294 if (result === 'false') {
297 return result || undefined;
300 /*----------------------------------------------------------------------------*/
303 * The `Job#remove` and `Tunnel#stop` callback used by `Jobs#restart`
304 * and `Tunnel#restart` respectively.
308 function onGenericRestart() {
309 this.restarting = false;
310 this.emit('restart');
315 * The `request.put` and `SauceTunnel#stop` callback used by `Jobs#stop`
316 * and `Tunnel#stop` respectively.
319 * @param {Object} [error] The error object.
321 function onGenericStop(error) {
322 this.running = this.stopping = false;
323 this.emit('stop', error);
327 * The `request.del` callback used by `Jobs#remove`.
331 function onJobRemove(error, res, body) {
332 this.id = this.taskId = this.url = null;
333 this.removing = false;
338 * The `Job#remove` callback used by `Jobs#reset`.
342 function onJobReset() {
344 this.failed = this.resetting = false;
345 this._pollerId = this.id = this.result = this.taskId = this.url = null;
350 * The `request.post` callback used by `Jobs#start`.
353 * @param {Object} [error] The error object.
354 * @param {Object} res The response data object.
355 * @param {Object} body The response body JSON object.
357 function onJobStart(error, res, body) {
358 this.starting = false;
363 var statusCode = _.get(res, 'statusCode'),
364 taskId = _.first(_.get(body, 'js tests'));
366 if (error || !taskId || statusCode != 200) {
367 if (this.attempts < this.retries) {
371 var na = 'unavailable',
372 bodyStr = _.isObject(body) ? '\n' + JSON.stringify(body) : na,
373 statusStr = _.isFinite(statusCode) ? statusCode : na;
376 console.error('Failed to start job; status: %s, body: %s', statusStr, bodyStr);
378 console.error(error);
381 this.emit('complete');
385 this.taskId = taskId;
386 this.timestamp = _.now();
392 * The `request.post` callback used by `Job#status`.
395 * @param {Object} [error] The error object.
396 * @param {Object} res The response data object.
397 * @param {Object} body The response body JSON object.
399 function onJobStatus(error, res, body) {
400 this.checking = false;
402 if (!this.running || this.stopping) {
405 var completed = _.get(body, 'completed', false),
406 data = _.first(_.get(body, 'js tests')),
407 elapsed = (_.now() - this.timestamp) / 1000,
408 jobId = _.get(data, 'job_id', null),
409 jobResult = _.get(data, 'result', null),
410 jobStatus = _.get(data, 'status', ''),
411 jobUrl = _.get(data, 'url', null),
412 expired = (elapsed >= queueTimeout && !_.includes(jobStatus, 'in progress')),
413 options = this.options,
414 platform = options.platforms[0];
416 if (_.isObject(jobResult)) {
417 var message = _.get(jobResult, 'message');
419 if (typeof jobResult == 'string') {
424 if (isJobId(jobId)) {
426 this.result = jobResult;
431 this.emit('status', jobStatus);
433 if (!completed && !expired) {
434 this._pollerId = _.delay(_.bind(this.status, this), this.statusInterval * 1000);
437 var description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + _.startCase(platform[0]),
438 errored = !jobResult || !jobResult.passed || reError.test(message) || reError.test(jobStatus),
439 failures = _.get(jobResult, 'failed'),
440 label = options.name + ':',
441 tunnel = this.tunnel;
443 if (errored || failures) {
444 if (errored && this.attempts < this.retries) {
448 var details = 'See ' + jobUrl + ' for details.';
453 console.error(label + ' %s ' + chalk.red('failed') + ' %d test' + (failures > 1 ? 's' : '') + '. %s', description, failures, details);
455 else if (tunnel.attempts < tunnel.retries) {
460 if (message === undefined) {
461 message = 'Results are unavailable. ' + details;
463 console.error(label, description, chalk.red('failed') + ';', message);
468 console.log(label, description, chalk.green('passed'));
470 this.running = false;
471 this.emit('complete');
475 * The `SauceTunnel#start` callback used by `Tunnel#start`.
478 * @param {boolean} success The connection success indicator.
480 function onTunnelStart(success) {
481 this.starting = false;
483 if (this._timeoutId) {
484 clearTimeout(this._timeoutId);
485 this._timeoutId = null;
488 if (this.attempts < this.retries) {
493 console.error('Failed to open Sauce Connect tunnel');
497 console.log('Sauce Connect tunnel opened');
499 var jobs = this.jobs;
500 push.apply(jobs.queue, jobs.all);
505 console.log('Starting jobs...');
509 /*----------------------------------------------------------------------------*/
512 * The Job constructor.
515 * @param {Object} [properties] The properties to initialize a job with.
517 function Job(properties) {
518 EventEmitter.call(this);
521 _.merge(this, properties);
522 _.defaults(this.options, _.cloneDeep(jobOptions));
525 this.checking = this.failed = this.removing = this.resetting = this.restarting = this.running = this.starting = this.stopping = false;
526 this._pollerId = this.id = this.result = this.taskId = this.url = null;
529 util.inherits(Job, EventEmitter);
535 * @param {Function} callback The function called once the job is removed.
536 * @param {Object} Returns the job instance.
538 Job.prototype.remove = function(callback) {
539 this.once('remove', _.iteratee(callback));
543 this.removing = true;
544 return this.stop(function() {
545 var onRemove = _.bind(onJobRemove, this);
550 request.del(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}')(this), {
551 'auth': { 'user': this.user, 'pass': this.pass }
560 * @param {Function} callback The function called once the job is reset.
561 * @param {Object} Returns the job instance.
563 Job.prototype.reset = function(callback) {
564 this.once('reset', _.iteratee(callback));
565 if (this.resetting) {
568 this.resetting = true;
569 return this.remove(onJobReset);
576 * @param {Function} callback The function called once the job is restarted.
577 * @param {Object} Returns the job instance.
579 Job.prototype.restart = function(callback) {
580 this.once('restart', _.iteratee(callback));
581 if (this.restarting) {
584 this.restarting = true;
586 var options = this.options,
587 platform = options.platforms[0],
588 description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + _.startCase(platform[0]),
589 label = options.name + ':';
592 console.log('%s %s restart %d of %d', label, description, ++this.attempts, this.retries);
594 return this.remove(onGenericRestart);
601 * @param {Function} callback The function called once the job is started.
602 * @param {Object} Returns the job instance.
604 Job.prototype.start = function(callback) {
605 this.once('start', _.iteratee(callback));
606 if (this.starting || this.running) {
609 this.starting = true;
610 request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests')(this), {
611 'auth': { 'user': this.user, 'pass': this.pass },
613 }, _.bind(onJobStart, this));
619 * Checks the status of a job.
622 * @param {Function} callback The function called once the status is resolved.
623 * @param {Object} Returns the job instance.
625 Job.prototype.status = function(callback) {
626 this.once('status', _.iteratee(callback));
627 if (this.checking || this.removing || this.resetting || this.restarting || this.starting || this.stopping) {
630 this._pollerId = null;
631 this.checking = true;
632 request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests/status')(this), {
633 'auth': { 'user': this.user, 'pass': this.pass },
634 'json': { 'js tests': [this.taskId] }
635 }, _.bind(onJobStatus, this));
644 * @param {Function} callback The function called once the job is stopped.
645 * @param {Object} Returns the job instance.
647 Job.prototype.stop = function(callback) {
648 this.once('stop', _.iteratee(callback));
652 this.stopping = true;
653 if (this._pollerId) {
654 clearTimeout(this._pollerId);
655 this._pollerId = null;
656 this.checking = false;
658 var onStop = _.bind(onGenericStop, this);
659 if (!this.running || !this.id) {
663 request.put(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}/stop')(this), {
664 'auth': { 'user': this.user, 'pass': this.pass }
670 /*----------------------------------------------------------------------------*/
673 * The Tunnel constructor.
676 * @param {Object} [properties] The properties to initialize the tunnel with.
678 function Tunnel(properties) {
679 EventEmitter.call(this);
681 _.merge(this, properties);
686 var all = _.map(this.platforms, _.bind(function(platform) {
687 return new Job(_.merge({
691 'options': { 'platforms': [platform] }
701 _.invokeMap(all, 'on', 'complete', function() {
702 _.pull(active, this);
704 success = !this.failed;
706 if (++completed == total) {
707 tunnel.stop(_.partial(tunnel.emit, 'complete', success));
713 _.invokeMap(all, 'on', 'restart', function() {
714 if (!_.includes(restarted, this)) {
715 restarted.push(this);
717 // Restart tunnel if all active jobs have restarted.
718 var threshold = Math.min(all.length, _.isFinite(throttled) ? throttled : 3);
719 if (tunnel.attempts < tunnel.retries &&
720 active.length >= threshold && _.isEmpty(_.difference(active, restarted))) {
725 this.on('restart', function() {
728 restarted.length = 0;
731 this._timeoutId = null;
733 this.restarting = this.running = this.starting = this.stopping = false;
734 this.jobs = { 'active': active, 'all': all, 'queue': queue };
735 this.connection = new SauceTunnel(this.user, this.pass, this.id, this.tunneled, ['-P', '0']);
738 util.inherits(Tunnel, EventEmitter);
741 * Restarts the tunnel.
744 * @param {Function} callback The function called once the tunnel is restarted.
746 Tunnel.prototype.restart = function(callback) {
747 this.once('restart', _.iteratee(callback));
748 if (this.restarting) {
751 this.restarting = true;
754 console.log('Tunnel %s: restart %d of %d', this.id, ++this.attempts, this.retries);
756 var jobs = this.jobs,
757 active = jobs.active,
760 var reset = _.after(all.length, _.bind(this.stop, this, onGenericRestart)),
761 stop = _.after(active.length, _.partial(_.invokeMap, all, 'reset', reset));
763 if (_.isEmpty(active)) {
766 if (_.isEmpty(all)) {
769 _.invokeMap(active, 'stop', function() {
770 _.pull(active, this);
774 if (this._timeoutId) {
775 clearTimeout(this._timeoutId);
776 this._timeoutId = null;
785 * @param {Function} callback The function called once the tunnel is started.
786 * @param {Object} Returns the tunnel instance.
788 Tunnel.prototype.start = function(callback) {
789 this.once('start', _.iteratee(callback));
790 if (this.starting || this.running) {
793 this.starting = true;
796 console.log('Opening Sauce Connect tunnel...');
798 var onStart = _.bind(onTunnelStart, this);
800 this._timeoutId = _.delay(onStart, this.timeout * 1000, false);
802 this.connection.start(onStart);
807 * Removes jobs from the queue and starts them.
810 * @param {Object} Returns the tunnel instance.
812 Tunnel.prototype.dequeue = function() {
815 active = jobs.active,
817 throttled = this.throttled;
819 while (queue.length && (active.length < throttled)) {
820 var job = queue.shift();
822 _.delay(_.bind(job.start, job), ++count * 1000);
831 * @param {Function} callback The function called once the tunnel is stopped.
832 * @param {Object} Returns the tunnel instance.
834 Tunnel.prototype.stop = function(callback) {
835 this.once('stop', _.iteratee(callback));
839 this.stopping = true;
842 console.log('Shutting down Sauce Connect tunnel...');
844 var jobs = this.jobs,
845 active = jobs.active;
847 var stop = _.after(active.length, _.bind(function() {
848 var onStop = _.bind(onGenericStop, this);
850 this.connection.stop(onStop);
856 jobs.queue.length = 0;
857 if (_.isEmpty(active)) {
860 _.invokeMap(active, 'stop', function() {
861 _.pull(active, this);
865 if (this._timeoutId) {
866 clearTimeout(this._timeoutId);
867 this._timeoutId = null;
872 /*----------------------------------------------------------------------------*/
874 // Cleanup any inline logs when exited via `ctrl+c`.
875 process.on('SIGINT', function() {
880 // Create a web server for the current working directory.
881 http.createServer(function(req, res) {
882 // See http://msdn.microsoft.com/en-us/library/ff955275(v=vs.85).aspx.
883 if (compatMode && path.extname(url.parse(req.url).pathname) == '.html') {
884 res.setHeader('X-UA-Compatible', 'IE=' + compatMode);
889 // Setup Sauce Connect so we can use this server from Sauce Labs.
890 var tunnel = new Tunnel({
894 'job': { 'retries': maxJobRetries, 'statusInterval': statusInterval },
895 'platforms': platforms,
896 'retries': maxTunnelRetries,
897 'throttled': throttled,
898 'tunneled': tunneled,
899 'timeout': tunnelTimeout
902 tunnel.on('complete', function(success) {
903 process.exit(success ? 0 : 1);
908 setInterval(logThrobber, throbberDelay);