Built motion from commit 76eb00b9e.|1.0.24
[motion.git] / public / bower_components / lodash / test / saucelabs.js
1 #!/usr/bin/env node
2 'use strict';
3
4 /** Environment shortcut. */
5 var env = process.env;
6
7 /** Load Node.js modules. */
8 var EventEmitter = require('events').EventEmitter,
9     http = require('http'),
10     path = require('path'),
11     url = require('url'),
12     util = require('util');
13
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');
20
21 /** Used for Sauce Labs credentials. */
22 var accessKey = env.SAUCE_ACCESS_KEY,
23     username = env.SAUCE_USERNAME;
24
25 /** Used as the default maximum number of times to retry a job and tunnel. */
26 var maxJobRetries = 3,
27     maxTunnelRetries = 3;
28
29 /** Used as the static file server middleware. */
30 var mount = ecstatic({
31   'cache': 'no-cache',
32   'root': process.cwd()
33 });
34
35 /** Used as the list of ports supported by Sauce Connect. */
36 var ports = [
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,
41   55001
42 ];
43
44 /** Used by `logInline` to clear previously logged messages. */
45 var prevLine = '';
46
47 /** Method shortcut. */
48 var push = Array.prototype.push;
49
50 /** Used to detect error messages. */
51 var reError = /(?:\be|E)rror\b/;
52
53 /** Used to detect valid job ids. */
54 var reJobId = /^[a-z0-9]{32}$/;
55
56 /** Used to display the wait throbber. */
57 var throbberDelay = 500,
58     waitCount = -1;
59
60 /**
61  * Used as Sauce Labs config values.
62  * See the [Sauce Labs documentation](https://docs.saucelabs.com/reference/test-configuration/)
63  * for more details.
64  */
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);
89
90 /** Used to convert Sauce Labs browser identifiers to their formal names. */
91 var browserNameMap = {
92   'googlechrome': 'Chrome',
93   'iehta': 'Internet Explorer',
94   'ipad': 'iPad',
95   'iphone': 'iPhone',
96   'microsoftedge': 'Edge'
97 };
98
99 /** List of platforms to load the runner on. */
100 var platforms = [
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']
112 ];
113
114 /** Used to tailor the `platforms` array. */
115 var isAMD = _.includes(tags, 'amd'),
116     isBackbone = _.includes(tags, 'backbone'),
117     isModern = _.includes(tags, 'modern');
118
119 // The platforms to test IE compatibility modes.
120 if (compatMode) {
121   platforms = [
122     ['Windows 10', 'internet explorer', '11'],
123     ['Windows 8', 'internet explorer', '10'],
124     ['Windows 7', 'internet explorer', '9'],
125     ['Windows 7', 'internet explorer', '8']
126   ];
127 }
128 // The platforms for AMD tests.
129 if (isAMD) {
130   platforms = _.filter(platforms, function(platform) {
131     var browser = browserName(platform[1]),
132         version = +platform[2];
133
134     switch (browser) {
135       case 'Android': return version >= 4.4;
136       case 'Opera': return version >= 10;
137     }
138     return true;
139   });
140 }
141 // The platforms for Backbone tests.
142 if (isBackbone) {
143   platforms = _.filter(platforms, function(platform) {
144     var browser = browserName(platform[1]),
145         version = +platform[2];
146
147     switch (browser) {
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;
152     }
153     return true;
154   });
155 }
156 // The platforms for modern builds.
157 if (isModern) {
158   platforms = _.filter(platforms, function(platform) {
159     var browser = browserName(platform[1]),
160         version = +platform[2];
161
162     switch (browser) {
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;
169     }
170     return true;
171   });
172 }
173
174 /** Used as the default `Job` options object. */
175 var jobOptions = {
176   'build': build,
177   'command-timeout': commandTimeout,
178   'custom-data': customData,
179   'device-orientation': deviceOrientation,
180   'framework': framework,
181   'idle-timeout': idleTimeout,
182   'max-duration': maxDuration,
183   'name': jobName,
184   'public': publicAccess,
185   'platforms': platforms,
186   'record-screenshots': recordScreenshots,
187   'record-video': recordVideo,
188   'sauce-advisor': advisor,
189   'tags': tags,
190   'url': runnerUrl,
191   'video-upload-on-pass': videoUploadOnPass
192 };
193
194 if (publicAccess === true) {
195   jobOptions['public'] = 'public';
196 }
197 if (tunneled) {
198   jobOptions['tunnel-identifier'] = tunnelId;
199 }
200
201 /*----------------------------------------------------------------------------*/
202
203 /**
204  * Resolves the formal browser name for a given Sauce Labs browser identifier.
205  *
206  * @private
207  * @param {string} identifier The browser identifier.
208  * @returns {string} Returns the formal browser name.
209  */
210 function browserName(identifier) {
211   return browserNameMap[identifier] || _.startCase(identifier);
212 }
213
214 /**
215  * Gets the value for the given option name. If no value is available the
216  * `defaultValue` is returned.
217  *
218  * @private
219  * @param {string} name The name of the option.
220  * @param {*} defaultValue The default option value.
221  * @returns {*} Returns the option value.
222  */
223 function getOption(name, defaultValue) {
224   var isArr = _.isArray(defaultValue);
225   return _.reduce(process.argv, function(result, value) {
226     if (isArr) {
227       value = optionToArray(name, value);
228       return _.isEmpty(value) ? result : value;
229     }
230     value = optionToValue(name, value);
231
232     return value == null ? result : value;
233   }, defaultValue);
234 }
235
236 /**
237  * Checks if `value` is a job ID.
238  *
239  * @private
240  * @param {*} value The value to check.
241  * @returns {boolean} Returns `true` if `value` is a job ID, else `false`.
242  */
243 function isJobId(value) {
244   return reJobId.test(value);
245 }
246
247 /**
248  * Writes an inline message to standard output.
249  *
250  * @private
251  * @param {string} [text=''] The text to log.
252  */
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');
257 }
258
259 /**
260  * Writes the wait throbber to standard output.
261  *
262  * @private
263  */
264 function logThrobber() {
265   logInline('Please wait' + _.repeat('.', (++waitCount % 3) + 1));
266 }
267
268 /**
269  * Converts a comma separated option value into an array.
270  *
271  * @private
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.
275  */
276 function optionToArray(name, string) {
277   return _.compact(_.invokeMap((optionToValue(name, string) || '').split(/, */), 'trim'));
278 }
279
280 /**
281  * Extracts the option value from an option string.
282  *
283  * @private
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`.
287  */
288 function optionToValue(name, string) {
289   var result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'));
290   if (result) {
291     result = _.get(result, 1);
292     result = result ? _.trim(result) : true;
293   }
294   if (result === 'false') {
295     return false;
296   }
297   return result || undefined;
298 }
299
300 /*----------------------------------------------------------------------------*/
301
302 /**
303  * The `Job#remove` and `Tunnel#stop` callback used by `Jobs#restart`
304  * and `Tunnel#restart` respectively.
305  *
306  * @private
307  */
308 function onGenericRestart() {
309   this.restarting = false;
310   this.emit('restart');
311   this.start();
312 }
313
314 /**
315  * The `request.put` and `SauceTunnel#stop` callback used by `Jobs#stop`
316  * and `Tunnel#stop` respectively.
317  *
318  * @private
319  * @param {Object} [error] The error object.
320  */
321 function onGenericStop(error) {
322   this.running = this.stopping = false;
323   this.emit('stop', error);
324 }
325
326 /**
327  * The `request.del` callback used by `Jobs#remove`.
328  *
329  * @private
330  */
331 function onJobRemove(error, res, body) {
332   this.id = this.taskId = this.url = null;
333   this.removing = false;
334   this.emit('remove');
335 }
336
337 /**
338  * The `Job#remove` callback used by `Jobs#reset`.
339  *
340  * @private
341  */
342 function onJobReset() {
343   this.attempts = 0;
344   this.failed = this.resetting = false;
345   this._pollerId = this.id = this.result = this.taskId = this.url = null;
346   this.emit('reset');
347 }
348
349 /**
350  * The `request.post` callback used by `Jobs#start`.
351  *
352  * @private
353  * @param {Object} [error] The error object.
354  * @param {Object} res The response data object.
355  * @param {Object} body The response body JSON object.
356  */
357 function onJobStart(error, res, body) {
358   this.starting = false;
359
360   if (this.stopping) {
361     return;
362   }
363   var statusCode = _.get(res, 'statusCode'),
364       taskId = _.first(_.get(body, 'js tests'));
365
366   if (error || !taskId || statusCode != 200) {
367     if (this.attempts < this.retries) {
368       this.restart();
369       return;
370     }
371     var na = 'unavailable',
372         bodyStr = _.isObject(body) ? '\n' + JSON.stringify(body) : na,
373         statusStr = _.isFinite(statusCode) ? statusCode : na;
374
375     logInline();
376     console.error('Failed to start job; status: %s, body: %s', statusStr, bodyStr);
377     if (error) {
378       console.error(error);
379     }
380     this.failed = true;
381     this.emit('complete');
382     return;
383   }
384   this.running = true;
385   this.taskId = taskId;
386   this.timestamp = _.now();
387   this.emit('start');
388   this.status();
389 }
390
391 /**
392  * The `request.post` callback used by `Job#status`.
393  *
394  * @private
395  * @param {Object} [error] The error object.
396  * @param {Object} res The response data object.
397  * @param {Object} body The response body JSON object.
398  */
399 function onJobStatus(error, res, body) {
400   this.checking = false;
401
402   if (!this.running || this.stopping) {
403     return;
404   }
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];
415
416   if (_.isObject(jobResult)) {
417     var message = _.get(jobResult, 'message');
418   } else {
419     if (typeof jobResult == 'string') {
420       message = jobResult;
421     }
422     jobResult = null;
423   }
424   if (isJobId(jobId)) {
425     this.id = jobId;
426     this.result = jobResult;
427     this.url = jobUrl;
428   } else {
429     completed = false;
430   }
431   this.emit('status', jobStatus);
432
433   if (!completed && !expired) {
434     this._pollerId = _.delay(_.bind(this.status, this), this.statusInterval * 1000);
435     return;
436   }
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;
442
443   if (errored || failures) {
444     if (errored && this.attempts < this.retries) {
445       this.restart();
446       return;
447     }
448     var details = 'See ' + jobUrl + ' for details.';
449     this.failed = true;
450
451     logInline();
452     if (failures) {
453       console.error(label + ' %s ' + chalk.red('failed') + ' %d test' + (failures > 1 ? 's' : '') + '. %s', description, failures, details);
454     }
455     else if (tunnel.attempts < tunnel.retries) {
456       tunnel.restart();
457       return;
458     }
459     else {
460       if (message === undefined) {
461         message = 'Results are unavailable. ' + details;
462       }
463       console.error(label, description, chalk.red('failed') + ';', message);
464     }
465   }
466   else {
467     logInline();
468     console.log(label, description, chalk.green('passed'));
469   }
470   this.running = false;
471   this.emit('complete');
472 }
473
474 /**
475  * The `SauceTunnel#start` callback used by `Tunnel#start`.
476  *
477  * @private
478  * @param {boolean} success The connection success indicator.
479  */
480 function onTunnelStart(success) {
481   this.starting = false;
482
483   if (this._timeoutId) {
484     clearTimeout(this._timeoutId);
485     this._timeoutId = null;
486   }
487   if (!success) {
488     if (this.attempts < this.retries) {
489       this.restart();
490       return;
491     }
492     logInline();
493     console.error('Failed to open Sauce Connect tunnel');
494     process.exit(2);
495   }
496   logInline();
497   console.log('Sauce Connect tunnel opened');
498
499   var jobs = this.jobs;
500   push.apply(jobs.queue, jobs.all);
501
502   this.running = true;
503   this.emit('start');
504
505   console.log('Starting jobs...');
506   this.dequeue();
507 }
508
509 /*----------------------------------------------------------------------------*/
510
511 /**
512  * The Job constructor.
513  *
514  * @private
515  * @param {Object} [properties] The properties to initialize a job with.
516  */
517 function Job(properties) {
518   EventEmitter.call(this);
519
520   this.options = {};
521   _.merge(this, properties);
522   _.defaults(this.options, _.cloneDeep(jobOptions));
523
524   this.attempts = 0;
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;
527 }
528
529 util.inherits(Job, EventEmitter);
530
531 /**
532  * Removes the job.
533  *
534  * @memberOf Job
535  * @param {Function} callback The function called once the job is removed.
536  * @param {Object} Returns the job instance.
537  */
538 Job.prototype.remove = function(callback) {
539   this.once('remove', _.iteratee(callback));
540   if (this.removing) {
541     return this;
542   }
543   this.removing = true;
544   return this.stop(function() {
545     var onRemove = _.bind(onJobRemove, this);
546     if (!this.id) {
547       _.defer(onRemove);
548       return;
549     }
550     request.del(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}')(this), {
551       'auth': { 'user': this.user, 'pass': this.pass }
552     }, onRemove);
553   });
554 };
555
556 /**
557  * Resets the job.
558  *
559  * @memberOf Job
560  * @param {Function} callback The function called once the job is reset.
561  * @param {Object} Returns the job instance.
562  */
563 Job.prototype.reset = function(callback) {
564   this.once('reset', _.iteratee(callback));
565   if (this.resetting) {
566     return this;
567   }
568   this.resetting = true;
569   return this.remove(onJobReset);
570 };
571
572 /**
573  * Restarts the job.
574  *
575  * @memberOf Job
576  * @param {Function} callback The function called once the job is restarted.
577  * @param {Object} Returns the job instance.
578  */
579 Job.prototype.restart = function(callback) {
580   this.once('restart', _.iteratee(callback));
581   if (this.restarting) {
582     return this;
583   }
584   this.restarting = true;
585
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 + ':';
590
591   logInline();
592   console.log('%s %s restart %d of %d', label, description, ++this.attempts, this.retries);
593
594   return this.remove(onGenericRestart);
595 };
596
597 /**
598  * Starts the job.
599  *
600  * @memberOf Job
601  * @param {Function} callback The function called once the job is started.
602  * @param {Object} Returns the job instance.
603  */
604 Job.prototype.start = function(callback) {
605   this.once('start', _.iteratee(callback));
606   if (this.starting || this.running) {
607     return this;
608   }
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 },
612     'json': this.options
613   }, _.bind(onJobStart, this));
614
615   return this;
616 };
617
618 /**
619  * Checks the status of a job.
620  *
621  * @memberOf Job
622  * @param {Function} callback The function called once the status is resolved.
623  * @param {Object} Returns the job instance.
624  */
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) {
628     return this;
629   }
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));
636
637   return this;
638 };
639
640 /**
641  * Stops the job.
642  *
643  * @memberOf Job
644  * @param {Function} callback The function called once the job is stopped.
645  * @param {Object} Returns the job instance.
646  */
647 Job.prototype.stop = function(callback) {
648   this.once('stop', _.iteratee(callback));
649   if (this.stopping) {
650     return this;
651   }
652   this.stopping = true;
653   if (this._pollerId) {
654     clearTimeout(this._pollerId);
655     this._pollerId = null;
656     this.checking = false;
657   }
658   var onStop = _.bind(onGenericStop, this);
659   if (!this.running || !this.id) {
660     _.defer(onStop);
661     return this;
662   }
663   request.put(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}/stop')(this), {
664     'auth': { 'user': this.user, 'pass': this.pass }
665   }, onStop);
666
667   return this;
668 };
669
670 /*----------------------------------------------------------------------------*/
671
672 /**
673  * The Tunnel constructor.
674  *
675  * @private
676  * @param {Object} [properties] The properties to initialize the tunnel with.
677  */
678 function Tunnel(properties) {
679   EventEmitter.call(this);
680
681   _.merge(this, properties);
682
683   var active = [],
684       queue = [];
685
686   var all = _.map(this.platforms, _.bind(function(platform) {
687     return new Job(_.merge({
688       'user': this.user,
689       'pass': this.pass,
690       'tunnel': this,
691       'options': { 'platforms': [platform] }
692     }, this.job));
693   }, this));
694
695   var completed = 0,
696       restarted = [],
697       success = true,
698       total = all.length,
699       tunnel = this;
700
701   _.invokeMap(all, 'on', 'complete', function() {
702     _.pull(active, this);
703     if (success) {
704       success = !this.failed;
705     }
706     if (++completed == total) {
707       tunnel.stop(_.partial(tunnel.emit, 'complete', success));
708       return;
709     }
710     tunnel.dequeue();
711   });
712
713   _.invokeMap(all, 'on', 'restart', function() {
714     if (!_.includes(restarted, this)) {
715       restarted.push(this);
716     }
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))) {
721       tunnel.restart();
722     }
723   });
724
725   this.on('restart', function() {
726     completed = 0;
727     success = true;
728     restarted.length = 0;
729   });
730
731   this._timeoutId = null;
732   this.attempts = 0;
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']);
736 }
737
738 util.inherits(Tunnel, EventEmitter);
739
740 /**
741  * Restarts the tunnel.
742  *
743  * @memberOf Tunnel
744  * @param {Function} callback The function called once the tunnel is restarted.
745  */
746 Tunnel.prototype.restart = function(callback) {
747   this.once('restart', _.iteratee(callback));
748   if (this.restarting) {
749     return this;
750   }
751   this.restarting = true;
752
753   logInline();
754   console.log('Tunnel %s: restart %d of %d', this.id, ++this.attempts, this.retries);
755
756   var jobs = this.jobs,
757       active = jobs.active,
758       all = jobs.all;
759
760   var reset = _.after(all.length, _.bind(this.stop, this, onGenericRestart)),
761       stop = _.after(active.length, _.partial(_.invokeMap, all, 'reset', reset));
762
763   if (_.isEmpty(active)) {
764     _.defer(stop);
765   }
766   if (_.isEmpty(all)) {
767     _.defer(reset);
768   }
769   _.invokeMap(active, 'stop', function() {
770     _.pull(active, this);
771     stop();
772   });
773
774   if (this._timeoutId) {
775     clearTimeout(this._timeoutId);
776     this._timeoutId = null;
777   }
778   return this;
779 };
780
781 /**
782  * Starts the tunnel.
783  *
784  * @memberOf Tunnel
785  * @param {Function} callback The function called once the tunnel is started.
786  * @param {Object} Returns the tunnel instance.
787  */
788 Tunnel.prototype.start = function(callback) {
789   this.once('start', _.iteratee(callback));
790   if (this.starting || this.running) {
791     return this;
792   }
793   this.starting = true;
794
795   logInline();
796   console.log('Opening Sauce Connect tunnel...');
797
798   var onStart = _.bind(onTunnelStart, this);
799   if (this.timeout) {
800     this._timeoutId = _.delay(onStart, this.timeout * 1000, false);
801   }
802   this.connection.start(onStart);
803   return this;
804 };
805
806 /**
807  * Removes jobs from the queue and starts them.
808  *
809  * @memberOf Tunnel
810  * @param {Object} Returns the tunnel instance.
811  */
812 Tunnel.prototype.dequeue = function() {
813   var count = 0,
814       jobs = this.jobs,
815       active = jobs.active,
816       queue = jobs.queue,
817       throttled = this.throttled;
818
819   while (queue.length && (active.length < throttled)) {
820     var job = queue.shift();
821     active.push(job);
822     _.delay(_.bind(job.start, job), ++count * 1000);
823   }
824   return this;
825 };
826
827 /**
828  * Stops the tunnel.
829  *
830  * @memberOf Tunnel
831  * @param {Function} callback The function called once the tunnel is stopped.
832  * @param {Object} Returns the tunnel instance.
833  */
834 Tunnel.prototype.stop = function(callback) {
835   this.once('stop', _.iteratee(callback));
836   if (this.stopping) {
837     return this;
838   }
839   this.stopping = true;
840
841   logInline();
842   console.log('Shutting down Sauce Connect tunnel...');
843
844   var jobs = this.jobs,
845       active = jobs.active;
846
847   var stop = _.after(active.length, _.bind(function() {
848     var onStop = _.bind(onGenericStop, this);
849     if (this.running) {
850       this.connection.stop(onStop);
851     } else {
852       onStop();
853     }
854   }, this));
855
856   jobs.queue.length = 0;
857   if (_.isEmpty(active)) {
858     _.defer(stop);
859   }
860   _.invokeMap(active, 'stop', function() {
861     _.pull(active, this);
862     stop();
863   });
864
865   if (this._timeoutId) {
866     clearTimeout(this._timeoutId);
867     this._timeoutId = null;
868   }
869   return this;
870 };
871
872 /*----------------------------------------------------------------------------*/
873
874 // Cleanup any inline logs when exited via `ctrl+c`.
875 process.on('SIGINT', function() {
876   logInline();
877   process.exit();
878 });
879
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);
885   }
886   mount(req, res);
887 }).listen(port);
888
889 // Setup Sauce Connect so we can use this server from Sauce Labs.
890 var tunnel = new Tunnel({
891   'user': username,
892   'pass': accessKey,
893   'id': tunnelId,
894   'job': { 'retries': maxJobRetries, 'statusInterval': statusInterval },
895   'platforms': platforms,
896   'retries': maxTunnelRetries,
897   'throttled': throttled,
898   'tunneled': tunneled,
899   'timeout': tunnelTimeout
900 });
901
902 tunnel.on('complete', function(success) {
903   process.exit(success ? 0 : 1);
904 });
905
906 tunnel.start();
907
908 setInterval(logThrobber, throbberDelay);