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