saucelabs.js 24 KB

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