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