PageRenderTime 24ms CodeModel.GetById 26ms RepoModel.GetById 1ms app.codeStats 0ms

/test/saucelabs.js

http://github.com/bestiejs/json3
JavaScript | 884 lines | 565 code | 102 blank | 217 comment | 87 complexity | 0ee9b86ab13d5ac7d9c9e7795c6425d3 MD5 | raw file
  1. #!/usr/bin/env node
  2. 'use strict';
  3. /** Environment shortcut. */
  4. var env = process.env;
  5. // if (env.TRAVIS_SECURE_ENV_VARS == 'false') {
  6. // console.log('Skipping Sauce Labs jobs; secure environment variables are unavailable');
  7. // process.exit(0);
  8. // }
  9. /** Load Node.js modules. */
  10. var EventEmitter = require('events').EventEmitter,
  11. http = require('http'),
  12. path = require('path'),
  13. url = require('url'),
  14. util = require('util');
  15. /** Load other modules. */
  16. var _ = require('lodash'),
  17. chalk = require('chalk'),
  18. ecstatic = require('ecstatic'),
  19. request = require('request'),
  20. SauceTunnel = require('sauce-tunnel');
  21. /** Used for Sauce Labs credentials. */
  22. var accessKey = env.SAUCE_ACCESS_KEY,
  23. username = env.SAUCE_USERNAME;
  24. /** Used as the default maximum number of times to retry a job and tunnel. */
  25. var maxJobRetries = 3,
  26. maxTunnelRetries = 3;
  27. /** Used as the static file server middleware. */
  28. var mount = ecstatic({
  29. 'cache': 'no-cache',
  30. 'root': process.cwd()
  31. });
  32. /** Used as the list of ports supported by Sauce Connect. */
  33. var ports = [
  34. 80, 443, 888, 2000, 2001, 2020, 2109, 2222, 2310, 3000, 3001, 3030, 3210,
  35. 3333, 4000, 4001, 4040, 4321, 4502, 4503, 4567, 5000, 5001, 5050, 5555, 5432,
  36. 6000, 6001, 6060, 6666, 6543, 7000, 7070, 7774, 7777, 8000, 8001, 8003, 8031,
  37. 8080, 8081, 8765, 8777, 8888, 9000, 9001, 9080, 9090, 9876, 9877, 9999, 49221,
  38. 55001
  39. ];
  40. /** Used by `logInline` to clear previously logged messages. */
  41. var prevLine = '';
  42. /** Method shortcut. */
  43. var push = Array.prototype.push;
  44. /** Used to detect error messages. */
  45. var reError = /(?:\be|E)rror\b/;
  46. /** Used to detect valid job ids. */
  47. var reJobId = /^[a-z0-9]{32}$/;
  48. /** Used to display the wait throbber. */
  49. var throbberDelay = 500,
  50. waitCount = -1;
  51. /**
  52. * Used as Sauce Labs config values.
  53. * See the [Sauce Labs documentation](https://docs.saucelabs.com/reference/test-configuration/)
  54. * for more details.
  55. */
  56. var advisor = getOption('advisor', false),
  57. build = getOption('build', (env.TRAVIS_COMMIT || '').slice(0, 10)),
  58. commandTimeout = getOption('commandTimeout', 90),
  59. compatMode = getOption('compatMode', null),
  60. customData = Function('return {' + getOption('customData', '').replace(/^\{|}$/g, '') + '}')(),
  61. deviceOrientation = getOption('deviceOrientation', 'portrait'),
  62. framework = getOption('framework', 'qunit'),
  63. idleTimeout = getOption('idleTimeout', 60),
  64. jobName = getOption('name', 'unit tests'),
  65. maxDuration = getOption('maxDuration', 120),
  66. port = ports[Math.min(_.sortedIndex(ports, getOption('port', 9001)), ports.length - 1)],
  67. publicAccess = getOption('public', true),
  68. queueTimeout = getOption('queueTimeout', 240),
  69. recordVideo = getOption('recordVideo', true),
  70. recordScreenshots = getOption('recordScreenshots', false),
  71. runner = getOption('runner', 'test/index.html').replace(/^\W+/, ''),
  72. runnerUrl = getOption('runnerUrl', 'http://localhost:' + port + '/' + runner),
  73. statusInterval = getOption('statusInterval', 5),
  74. tags = getOption('tags', []),
  75. throttled = getOption('throttled', 10),
  76. tunneled = getOption('tunneled', true),
  77. tunnelId = getOption('tunnelId', 'tunnel_' + (env.TRAVIS_JOB_ID || 0)),
  78. tunnelTimeout = getOption('tunnelTimeout', 120),
  79. videoUploadOnPass = getOption('videoUploadOnPass', false);
  80. /** Used to convert Sauce Labs browser identifiers to their formal names. */
  81. var browserNameMap = {
  82. 'googlechrome': 'Chrome',
  83. 'iehta': 'Internet Explorer',
  84. 'ipad': 'iPad',
  85. 'iphone': 'iPhone'
  86. };
  87. /** List of platforms to load the runner on. */
  88. var platforms = [
  89. ['Linux', 'android', '4.3'],
  90. ['Linux', 'android', '4.0'],
  91. ['Windows 8.1', 'firefox', '34'],
  92. ['Windows 8.1', 'firefox', '33'],
  93. ['Windows 8.1', 'firefox', '20'],
  94. ['Windows 8.1', 'chrome', '39'],
  95. ['Windows 8.1', 'chrome', '38'],
  96. ['Windows 8.1', 'internet explorer', '11'],
  97. ['Windows 8', 'internet explorer', '10'],
  98. ['Windows 7', 'internet explorer', '9'],
  99. ['Windows 7', 'internet explorer', '8'],
  100. ['Windows XP', 'internet explorer', '7'],
  101. ['Windows XP', 'internet explorer', '6'],
  102. ['Windows 7', 'opera', '12'],
  103. ['Windows 7', 'opera', '11'],
  104. ['OS X 10.9', 'ipad', '8.1'],
  105. ['OS X 10.6', 'ipad', '4'],
  106. ['OS X 10.10', 'safari', '8'],
  107. ['OS X 10.9', 'safari', '7'],
  108. ['OS X 10.8', 'safari', '6'],
  109. ['OS X 10.6', 'safari', '5']
  110. ];
  111. // The platforms to test IE compatibility modes.
  112. if (compatMode) {
  113. platforms = [
  114. ['Windows 8.1', 'internet explorer', '11'],
  115. ['Windows 8', 'internet explorer', '10'],
  116. ['Windows 7', 'internet explorer', '9'],
  117. ['Windows 7', 'internet explorer', '8']
  118. ];
  119. }
  120. /** Used as the default `Job` options object. */
  121. var jobOptions = {
  122. 'build': build,
  123. 'command-timeout': commandTimeout,
  124. 'custom-data': customData,
  125. 'device-orientation': deviceOrientation,
  126. 'framework': framework,
  127. 'idle-timeout': idleTimeout,
  128. 'max-duration': maxDuration,
  129. 'name': jobName,
  130. 'public': publicAccess,
  131. 'platforms': platforms,
  132. 'record-screenshots': recordScreenshots,
  133. 'record-video': recordVideo,
  134. 'sauce-advisor': advisor,
  135. 'tags': tags,
  136. 'url': runnerUrl,
  137. 'video-upload-on-pass': videoUploadOnPass
  138. };
  139. if (publicAccess === true) {
  140. jobOptions['public'] = 'public';
  141. }
  142. if (tunneled) {
  143. jobOptions['tunnel-identifier'] = tunnelId;
  144. }
  145. /*----------------------------------------------------------------------------*/
  146. /**
  147. * Resolves the formal browser name for a given Sauce Labs browser identifier.
  148. *
  149. * @private
  150. * @param {string} identifier The browser identifier.
  151. * @returns {string} Returns the formal browser name.
  152. */
  153. function browserName(identifier) {
  154. return browserNameMap[identifier] || capitalizeWords(identifier);
  155. }
  156. /**
  157. * Capitalizes the first character of each word in `string`.
  158. *
  159. * @private
  160. * @param {string} string The string to augment.
  161. * @returns {string} Returns the augmented string.
  162. */
  163. function capitalizeWords(string) {
  164. return _.map(string.split(' '), _.capitalize).join(' ');
  165. }
  166. /**
  167. * Gets the value for the given option name. If no value is available the
  168. * `defaultValue` is returned.
  169. *
  170. * @private
  171. * @param {string} name The name of the option.
  172. * @param {*} defaultValue The default option value.
  173. * @returns {*} Returns the option value.
  174. */
  175. function getOption(name, defaultValue) {
  176. var isArr = _.isArray(defaultValue);
  177. return _.reduce(process.argv, function(result, value) {
  178. if (isArr) {
  179. value = optionToArray(name, value);
  180. return _.isEmpty(value) ? result : value;
  181. }
  182. value = optionToValue(name, value);
  183. return value == null ? result : value;
  184. }, defaultValue);
  185. }
  186. /**
  187. * Checks if `value` is a job ID.
  188. *
  189. * @private
  190. * @param {*} value The value to check.
  191. * @returns {boolean} Returns `true` if `value` is a job ID, else `false`.
  192. */
  193. function isJobId(value) {
  194. return reJobId.test(value);
  195. }
  196. /**
  197. * Writes an inline message to standard output.
  198. *
  199. * @private
  200. * @param {string} [text=''] The text to log.
  201. */
  202. function logInline(text) {
  203. var blankLine = _.repeat(' ', _.size(prevLine));
  204. prevLine = text = _.trunc(text, 40);
  205. process.stdout.write(text + blankLine.slice(text.length) + '\r');
  206. }
  207. /**
  208. * Writes the wait throbber to standard output.
  209. *
  210. * @private
  211. */
  212. function logThrobber() {
  213. logInline('Please wait' + _.repeat('.', (++waitCount % 3) + 1));
  214. }
  215. /**
  216. * Converts a comma separated option value into an array.
  217. *
  218. * @private
  219. * @param {string} name The name of the option to inspect.
  220. * @param {string} string The options string.
  221. * @returns {Array} Returns the new converted array.
  222. */
  223. function optionToArray(name, string) {
  224. return _.compact(_.invoke((optionToValue(name, string) || '').split(/, */), 'trim'));
  225. }
  226. /**
  227. * Extracts the option value from an option string.
  228. *
  229. * @private
  230. * @param {string} name The name of the option to inspect.
  231. * @param {string} string The options string.
  232. * @returns {string|undefined} Returns the option value, else `undefined`.
  233. */
  234. function optionToValue(name, string) {
  235. var result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'));
  236. if (result) {
  237. result = _.result(result, 1);
  238. result = result ? _.trim(result) : true;
  239. }
  240. if (result === 'false') {
  241. return false;
  242. }
  243. return result || undefined;
  244. }
  245. /*----------------------------------------------------------------------------*/
  246. /**
  247. * The `Job#remove` and `Tunnel#stop` callback used by `Jobs#restart`
  248. * and `Tunnel#restart` respectively.
  249. *
  250. * @private
  251. */
  252. function onGenericRestart() {
  253. this.restarting = false;
  254. this.emit('restart');
  255. this.start();
  256. }
  257. /**
  258. * The `request.put` and `SauceTunnel#stop` callback used by `Jobs#stop`
  259. * and `Tunnel#stop` respectively.
  260. *
  261. * @private
  262. * @param {Object} [error] The error object.
  263. */
  264. function onGenericStop(error) {
  265. this.running = this.stopping = false;
  266. this.emit('stop', error);
  267. }
  268. /**
  269. * The `request.del` callback used by `Jobs#remove`.
  270. *
  271. * @private
  272. */
  273. function onJobRemove(error, res, body) {
  274. this.id = this.taskId = this.url = null;
  275. this.removing = false;
  276. this.emit('remove');
  277. }
  278. /**
  279. * The `Job#remove` callback used by `Jobs#reset`.
  280. *
  281. * @private
  282. */
  283. function onJobReset() {
  284. this.attempts = 0;
  285. this.failed = this.resetting = false;
  286. this._pollerId = this.id = this.result = this.taskId = this.url = null;
  287. this.emit('reset');
  288. }
  289. /**
  290. * The `request.post` callback used by `Jobs#start`.
  291. *
  292. * @private
  293. * @param {Object} [error] The error object.
  294. * @param {Object} res The response data object.
  295. * @param {Object} body The response body JSON object.
  296. */
  297. function onJobStart(error, res, body) {
  298. this.starting = false;
  299. if (this.stopping) {
  300. return;
  301. }
  302. var statusCode = _.result(res, 'statusCode'),
  303. taskId = _.first(_.result(body, 'js tests'));
  304. if (error || !taskId || statusCode != 200) {
  305. if (this.attempts < this.retries) {
  306. this.restart();
  307. return;
  308. }
  309. var na = 'unavailable',
  310. bodyStr = _.isObject(body) ? '\n' + JSON.stringify(body) : na,
  311. statusStr = _.isFinite(statusCode) ? statusCode : na;
  312. logInline();
  313. console.error('Failed to start job; status: %s, body: %s', statusStr, bodyStr);
  314. if (error) {
  315. console.error(error);
  316. }
  317. this.failed = true;
  318. this.emit('complete');
  319. return;
  320. }
  321. this.running = true;
  322. this.taskId = taskId;
  323. this.timestamp = _.now();
  324. this.emit('start');
  325. this.status();
  326. }
  327. /**
  328. * The `request.post` callback used by `Job#status`.
  329. *
  330. * @private
  331. * @param {Object} [error] The error object.
  332. * @param {Object} res The response data object.
  333. * @param {Object} body The response body JSON object.
  334. */
  335. function onJobStatus(error, res, body) {
  336. this.checking = false;
  337. if (!this.running || this.stopping) {
  338. return;
  339. }
  340. var completed = _.result(body, 'completed', false),
  341. data = _.first(_.result(body, 'js tests')),
  342. elapsed = (_.now() - this.timestamp) / 1000,
  343. jobId = _.result(data, 'job_id', null),
  344. jobResult = _.result(data, 'result', null),
  345. jobStatus = _.result(data, 'status', ''),
  346. jobUrl = _.result(data, 'url', null),
  347. expired = (elapsed >= queueTimeout && !_.includes(jobStatus, 'in progress')),
  348. options = this.options,
  349. platform = options.platforms[0];
  350. if (_.isObject(jobResult)) {
  351. var message = _.result(jobResult, 'message');
  352. } else {
  353. if (typeof jobResult == 'string') {
  354. message = jobResult;
  355. }
  356. jobResult = null;
  357. }
  358. if (isJobId(jobId)) {
  359. this.id = jobId;
  360. this.result = jobResult;
  361. this.url = jobUrl;
  362. } else {
  363. completed = false;
  364. }
  365. this.emit('status', jobStatus);
  366. if (!completed && !expired) {
  367. this._pollerId = _.delay(_.bind(this.status, this), this.statusInterval * 1000);
  368. return;
  369. }
  370. var description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + capitalizeWords(platform[0]),
  371. errored = !jobResult || !jobResult.passed || reError.test(message) || reError.test(jobStatus),
  372. failures = _.result(jobResult, 'failed'),
  373. label = options.name + ':',
  374. tunnel = this.tunnel;
  375. if (errored || failures) {
  376. if (errored && this.attempts < this.retries) {
  377. this.restart();
  378. return;
  379. }
  380. var details = 'See ' + jobUrl + ' for details.';
  381. this.failed = true;
  382. logInline();
  383. if (failures) {
  384. console.error(label + ' %s ' + chalk.red('failed') + ' %d test' + (failures > 1 ? 's' : '') + '. %s', description, failures, details);
  385. }
  386. else if (tunnel.attempts < tunnel.retries) {
  387. tunnel.restart();
  388. return;
  389. }
  390. else {
  391. if (typeof message == 'undefined') {
  392. message = 'Results are unavailable. ' + details;
  393. }
  394. console.error(label, description, chalk.red('failed') + ';', message);
  395. }
  396. }
  397. else {
  398. logInline();
  399. console.log(label, description, chalk.green('passed'));
  400. }
  401. this.running = false;
  402. this.emit('complete');
  403. }
  404. /**
  405. * The `SauceTunnel#start` callback used by `Tunnel#start`.
  406. *
  407. * @private
  408. * @param {boolean} success The connection success indicator.
  409. */
  410. function onTunnelStart(success) {
  411. this.starting = false;
  412. if (this._timeoutId) {
  413. clearTimeout(this._timeoutId);
  414. this._timeoutId = null;
  415. }
  416. if (!success) {
  417. if (this.attempts < this.retries) {
  418. this.restart();
  419. return;
  420. }
  421. logInline();
  422. console.error('Failed to open Sauce Connect tunnel');
  423. process.exit(2);
  424. }
  425. logInline();
  426. console.log('Sauce Connect tunnel opened');
  427. var jobs = this.jobs;
  428. push.apply(jobs.queue, jobs.all);
  429. this.running = true;
  430. this.emit('start');
  431. console.log('Starting jobs...');
  432. this.dequeue();
  433. }
  434. /*----------------------------------------------------------------------------*/
  435. /**
  436. * The Job constructor.
  437. *
  438. * @private
  439. * @param {Object} [properties] The properties to initialize a job with.
  440. */
  441. function Job(properties) {
  442. EventEmitter.call(this);
  443. this.options = {};
  444. this.retries = maxJobRetries;
  445. this.statusInterval = statusInterval;
  446. _.merge(this, properties);
  447. _.defaults(this.options, _.cloneDeep(jobOptions));
  448. this.attempts = 0;
  449. this.checking = this.failed = this.removing = this.resetting = this.restarting = this.running = this.starting = this.stopping = false;
  450. this._pollerId = this.id = this.result = this.taskId = this.url = null;
  451. }
  452. util.inherits(Job, EventEmitter);
  453. /**
  454. * Removes the job.
  455. *
  456. * @memberOf Job
  457. * @param {Function} callback The function called once the job is removed.
  458. * @param {Object} Returns the job instance.
  459. */
  460. Job.prototype.remove = function(callback) {
  461. this.once('remove', _.callback(callback));
  462. if (this.removing) {
  463. return this;
  464. }
  465. this.removing = true;
  466. return this.stop(function() {
  467. var onRemove = _.bind(onJobRemove, this);
  468. if (!this.id) {
  469. _.defer(onRemove);
  470. return;
  471. }
  472. request.del(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}')(this), {
  473. 'auth': { 'user': this.user, 'pass': this.pass }
  474. }, onRemove);
  475. });
  476. };
  477. /**
  478. * Resets the job.
  479. *
  480. * @memberOf Job
  481. * @param {Function} callback The function called once the job is reset.
  482. * @param {Object} Returns the job instance.
  483. */
  484. Job.prototype.reset = function(callback) {
  485. this.once('reset', _.callback(callback));
  486. if (this.resetting) {
  487. return this;
  488. }
  489. this.resetting = true;
  490. return this.remove(onJobReset);
  491. };
  492. /**
  493. * Restarts the job.
  494. *
  495. * @memberOf Job
  496. * @param {Function} callback The function called once the job is restarted.
  497. * @param {Object} Returns the job instance.
  498. */
  499. Job.prototype.restart = function(callback) {
  500. this.once('restart', _.callback(callback));
  501. if (this.restarting) {
  502. return this;
  503. }
  504. this.restarting = true;
  505. var options = this.options,
  506. platform = options.platforms[0],
  507. description = browserName(platform[1]) + ' ' + platform[2] + ' on ' + capitalizeWords(platform[0]),
  508. label = options.name + ':';
  509. logInline();
  510. console.log('%s %s restart %d of %d', label, description, ++this.attempts, this.retries);
  511. return this.remove(onGenericRestart);
  512. };
  513. /**
  514. * Starts the job.
  515. *
  516. * @memberOf Job
  517. * @param {Function} callback The function called once the job is started.
  518. * @param {Object} Returns the job instance.
  519. */
  520. Job.prototype.start = function(callback) {
  521. this.once('start', _.callback(callback));
  522. if (this.starting || this.running) {
  523. return this;
  524. }
  525. this.starting = true;
  526. request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests')(this), {
  527. 'auth': { 'user': this.user, 'pass': this.pass },
  528. 'json': this.options
  529. }, _.bind(onJobStart, this));
  530. return this;
  531. };
  532. /**
  533. * Checks the status of a job.
  534. *
  535. * @memberOf Job
  536. * @param {Function} callback The function called once the status is resolved.
  537. * @param {Object} Returns the job instance.
  538. */
  539. Job.prototype.status = function(callback) {
  540. this.once('status', _.callback(callback));
  541. if (this.checking || this.removing || this.resetting || this.restarting || this.starting || this.stopping) {
  542. return this;
  543. }
  544. this._pollerId = null;
  545. this.checking = true;
  546. request.post(_.template('https://saucelabs.com/rest/v1/${user}/js-tests/status')(this), {
  547. 'auth': { 'user': this.user, 'pass': this.pass },
  548. 'json': { 'js tests': [this.taskId] }
  549. }, _.bind(onJobStatus, this));
  550. return this;
  551. };
  552. /**
  553. * Stops the job.
  554. *
  555. * @memberOf Job
  556. * @param {Function} callback The function called once the job is stopped.
  557. * @param {Object} Returns the job instance.
  558. */
  559. Job.prototype.stop = function(callback) {
  560. this.once('stop', _.callback(callback));
  561. if (this.stopping) {
  562. return this;
  563. }
  564. this.stopping = true;
  565. if (this._pollerId) {
  566. clearTimeout(this._pollerId);
  567. this._pollerId = null;
  568. this.checking = false;
  569. }
  570. var onStop = _.bind(onGenericStop, this);
  571. if (!this.running || !this.id) {
  572. _.defer(onStop);
  573. return this;
  574. }
  575. request.put(_.template('https://saucelabs.com/rest/v1/${user}/jobs/${id}/stop')(this), {
  576. 'auth': { 'user': this.user, 'pass': this.pass }
  577. }, onStop);
  578. return this;
  579. };
  580. /*----------------------------------------------------------------------------*/
  581. /**
  582. * The Tunnel constructor.
  583. *
  584. * @private
  585. * @param {Object} [properties] The properties to initialize the tunnel with.
  586. */
  587. function Tunnel(properties) {
  588. EventEmitter.call(this);
  589. this.retries = maxTunnelRetries;
  590. _.merge(this, properties);
  591. var active = [],
  592. queue = [];
  593. var all = _.map(this.platforms, function(platform) {
  594. return new Job(_.merge({
  595. 'user': this.user,
  596. 'pass': this.pass,
  597. 'tunnel': this,
  598. 'options': { 'platforms': [platform] }
  599. }, this.job));
  600. }, this);
  601. var completed = 0,
  602. restarted = [],
  603. success = true,
  604. total = all.length,
  605. tunnel = this;
  606. _.invoke(all, 'on', 'complete', function() {
  607. _.pull(active, this);
  608. if (success) {
  609. success = !this.failed;
  610. }
  611. if (++completed == total) {
  612. tunnel.stop(_.partial(tunnel.emit, 'complete', success));
  613. return;
  614. }
  615. tunnel.dequeue();
  616. });
  617. _.invoke(all, 'on', 'restart', function() {
  618. if (!_.includes(restarted, this)) {
  619. restarted.push(this);
  620. }
  621. // Restart tunnel if all active jobs have restarted.
  622. var threshold = Math.min(all.length, _.isFinite(throttled) ? throttled : 3);
  623. if (tunnel.attempts < tunnel.retries &&
  624. active.length >= threshold && _.isEmpty(_.difference(active, restarted))) {
  625. tunnel.restart();
  626. }
  627. });
  628. this.on('restart', function() {
  629. completed = 0;
  630. success = true;
  631. restarted.length = 0;
  632. });
  633. this._timeoutId = null;
  634. this.attempts = 0;
  635. this.restarting = this.running = this.starting = this.stopping = false;
  636. this.jobs = { 'active': active, 'all': all, 'queue': queue };
  637. this.connection = new SauceTunnel(this.user, this.pass, this.id, this.tunneled, ['-P', '0']);
  638. }
  639. util.inherits(Tunnel, EventEmitter);
  640. /**
  641. * Restarts the tunnel.
  642. *
  643. * @memberOf Tunnel
  644. * @param {Function} callback The function called once the tunnel is restarted.
  645. */
  646. Tunnel.prototype.restart = function(callback) {
  647. this.once('restart', _.callback(callback));
  648. if (this.restarting) {
  649. return this;
  650. }
  651. this.restarting = true;
  652. logInline();
  653. console.log('Tunnel %s: restart %d of %d', this.id, ++this.attempts, this.retries);
  654. var jobs = this.jobs,
  655. active = jobs.active,
  656. all = jobs.all;
  657. var reset = _.after(all.length, _.bind(this.stop, this, onGenericRestart)),
  658. stop = _.after(active.length, _.partial(_.invoke, all, 'reset', reset));
  659. if (_.isEmpty(active)) {
  660. _.defer(stop);
  661. }
  662. if (_.isEmpty(all)) {
  663. _.defer(reset);
  664. }
  665. _.invoke(active, 'stop', function() {
  666. _.pull(active, this);
  667. stop();
  668. });
  669. if (this._timeoutId) {
  670. clearTimeout(this._timeoutId);
  671. this._timeoutId = null;
  672. }
  673. return this;
  674. };
  675. /**
  676. * Starts the tunnel.
  677. *
  678. * @memberOf Tunnel
  679. * @param {Function} callback The function called once the tunnel is started.
  680. * @param {Object} Returns the tunnel instance.
  681. */
  682. Tunnel.prototype.start = function(callback) {
  683. this.once('start', _.callback(callback));
  684. if (this.starting || this.running) {
  685. return this;
  686. }
  687. this.starting = true;
  688. logInline();
  689. console.log('Opening Sauce Connect tunnel...');
  690. var onStart = _.bind(onTunnelStart, this);
  691. if (this.timeout) {
  692. this._timeoutId = _.delay(onStart, this.timeout * 1000, false);
  693. }
  694. this.connection.start(onStart);
  695. return this;
  696. };
  697. /**
  698. * Removes jobs from the queue and starts them.
  699. *
  700. * @memberOf Tunnel
  701. * @param {Object} Returns the tunnel instance.
  702. */
  703. Tunnel.prototype.dequeue = function() {
  704. var jobs = this.jobs,
  705. active = jobs.active,
  706. queue = jobs.queue,
  707. throttled = this.throttled;
  708. while (queue.length && (active.length < throttled)) {
  709. active.push(queue.shift().start());
  710. }
  711. return this;
  712. };
  713. /**
  714. * Stops the tunnel.
  715. *
  716. * @memberOf Tunnel
  717. * @param {Function} callback The function called once the tunnel is stopped.
  718. * @param {Object} Returns the tunnel instance.
  719. */
  720. Tunnel.prototype.stop = function(callback) {
  721. this.once('stop', _.callback(callback));
  722. if (this.stopping) {
  723. return this;
  724. }
  725. this.stopping = true;
  726. logInline();
  727. console.log('Shutting down Sauce Connect tunnel...');
  728. var jobs = this.jobs,
  729. active = jobs.active;
  730. var stop = _.after(active.length, _.bind(function() {
  731. var onStop = _.bind(onGenericStop, this);
  732. if (this.running) {
  733. this.connection.stop(onStop);
  734. } else {
  735. onStop();
  736. }
  737. }, this));
  738. jobs.queue.length = 0;
  739. if (_.isEmpty(active)) {
  740. _.defer(stop);
  741. }
  742. _.invoke(active, 'stop', function() {
  743. _.pull(active, this);
  744. stop();
  745. });
  746. if (this._timeoutId) {
  747. clearTimeout(this._timeoutId);
  748. this._timeoutId = null;
  749. }
  750. return this;
  751. };
  752. /*----------------------------------------------------------------------------*/
  753. // Cleanup any inline logs when exited via `ctrl+c`.
  754. process.on('SIGINT', function() {
  755. logInline();
  756. process.exit();
  757. });
  758. // Create a web server for the current working directory.
  759. http.createServer(function(req, res) {
  760. // See http://msdn.microsoft.com/en-us/library/ff955275(v=vs.85).aspx.
  761. if (compatMode && path.extname(url.parse(req.url).pathname) == '.html') {
  762. res.setHeader('X-UA-Compatible', 'IE=' + compatMode);
  763. }
  764. mount(req, res);
  765. }).listen(port);
  766. // Setup Sauce Connect so we can use this server from Sauce Labs.
  767. var tunnel = new Tunnel({
  768. 'user': username,
  769. 'pass': accessKey,
  770. 'id': tunnelId,
  771. 'job': { 'retries': maxJobRetries, 'statusInterval': statusInterval },
  772. 'platforms': platforms,
  773. 'retries': maxTunnelRetries,
  774. 'throttled': throttled,
  775. 'tunneled': tunneled,
  776. 'timeout': tunnelTimeout
  777. });
  778. tunnel.on('complete', function(success) {
  779. process.exit(success ? 0 : 1);
  780. });
  781. tunnel.start();
  782. setInterval(logThrobber, throbberDelay);