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