1#!/usr/bin/env node
2'use strict';
3
4/** Environment shortcut. */
5var env = process.env;
6
7/** Load Node.js modules. */
8var 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. */
15var _ = 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. */
22var 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. */
26var maxJobRetries = 3,
27    maxTunnelRetries = 3;
28
29/** Used as the static file server middleware. */
30var mount = ecstatic({
31  'cache': 'no-cache',
32  'root': process.cwd()
33});
34
35/** Used as the list of ports supported by Sauce Connect. */
36var 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. */
45var prevLine = '';
46
47/** Method shortcut. */
48var push = Array.prototype.push;
49
50/** Used to detect error messages. */
51var reError = /(?:\be|E)rror\b/;
52
53/** Used to detect valid job ids. */
54var reJobId = /^[a-z0-9]{32}$/;
55
56/** Used to display the wait throbber. */
57var 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 */
65var 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. */
91var 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. */
100var 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. */
115var isAMD = _.includes(tags, 'amd'),
116    isBackbone = _.includes(tags, 'backbone'),
117    isModern = _.includes(tags, 'modern');
118
119// The platforms to test IE compatibility modes.
120if (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.
129if (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.
142if (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.
157if (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. */
175var 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
194if (publicAccess === true) {
195  jobOptions['public'] = 'public';
196}
197if (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 */
210function 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 */
223function 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 */
243function 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 */
253function 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 */
264function 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 */
276function 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 */
288function 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 */
308function 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 */
321function 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 */
331function 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 */
342function 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 */
357function 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 */
399function 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 */
480function 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 */
517function 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
529util.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 */
538Job.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 */
563Job.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 */
579Job.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 */
604Job.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 */
625Job.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 */
647Job.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 */
678function 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
738util.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 */
746Tunnel.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 */
788Tunnel.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 */
812Tunnel.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 */
834Tunnel.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`.
875process.on('SIGINT', function() {
876  logInline();
877  process.exit();
878});
879
880// Create a web server for the current working directory.
881http.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.
890var 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
902tunnel.on('complete', function(success) {
903  process.exit(success ? 0 : 1);
904});
905
906tunnel.start();
907
908setInterval(logThrobber, throbberDelay);
909