diff --git a/README.md b/README.md index 8c75770..f973be7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,13 @@ var sauceConnectLauncher = require('sauce-connect-launcher'), connectRetries: 0 // time to wait between connection retries in ms. (optional) - connectRetryTimeout: 5000 + connectRetryTimeout: 2000 + + // retry to download the sauce connect archive multiple times. (optional) + downloadRetries: 0 + + // time to wait between download retries in ms. (optional) + downloadRetryTimeout: 1000 // path to a sauce connect executable (optional) // by default the latest sauce connect version is downloaded diff --git a/lib/process_options.js b/lib/process_options.js index c56bf02..e7d96bb 100644 --- a/lib/process_options.js +++ b/lib/process_options.js @@ -17,7 +17,18 @@ module.exports = function processOptions(options) { options.accessKey = options.accessKey || process.env.SAUCE_ACCESS_KEY; return _.reduce( - _.omit(options, ["readyFileId", "verbose", "logger", "log", "connectRetries", "connectRetryTimeout", "detached", "connectVersion"]), + _.omit(options, [ + "readyFileId", + "verbose", + "logger", + "log", + "connectRetries", + "connectRetryTimeout", + "downloadRetries", + "downloadRetryTimeout", + "detached", + "connectVersion" + ]), function (argList, value, key) { if (typeof value === "undefined" || value === null) { return argList; diff --git a/lib/sauce-connect-launcher.js b/lib/sauce-connect-launcher.js index ce7c737..60b072f 100644 --- a/lib/sauce-connect-launcher.js +++ b/lib/sauce-connect-launcher.js @@ -21,7 +21,9 @@ var require("../package.json").sauceConnectLauncher.scVersion, tunnelIdRegExp = /Tunnel ID:\s*([a-z0-9]+)/i, portRegExp = /port\s*([0-9]+)/i, - tryRun = require("./try_run"); + tryRun = require("./try_run"), + defaultConnectRetryTimeout = 2000, + defaultDownloadRetryTimeout = 1000; function setWorkDir(workDir) { scDir = workDir; @@ -187,6 +189,10 @@ function fetchArchive(archiveName, archivefile, callback) { path: "/downloads/" + archiveName }); + req.on("error", function (err) { + callback(err); + }); + function removeArchive() { try { logger("Removing " + archivefile); @@ -205,6 +211,12 @@ function fetchArchive(archiveName, archivefile, callback) { logger("This will only happen once."); req.on("response", function (res) { + if (res.statusCode !== 200) { + logger(`Invalid response status: ${res.statusCode}`); + + return callback(new Error("Download failed with status code: " + res.statusCode)); + } + var len = parseInt(res.headers["content-length"], 10), prettyLen = (len / (1024 * 1024) + "").substr(0, 4); @@ -314,8 +326,14 @@ function getVersion(options, cb) { path: "/versions.json" }); + req.on("error", function (err) { + cb(err); + }); + req.on("response", function (res) { if (res.statusCode !== 200) { + logger(`Invalid response status: ${res.statusCode}`); + return cb(new Error("Fetching https://saucelabs.com/versions.json failed: " + res.statusCode)); } @@ -565,11 +583,25 @@ function connect(bin, options, callback) { } function run(version, options, callback) { - tryRun(0, options, function (tryCallback) { + tryRun(0, { + logger: options.logger, + retries: options.connectRetries, + timeout: options.connectRetryTimeout || defaultConnectRetryTimeout + }, function (tryCallback) { return connect(version, options, tryCallback); }, callback); } +function downloadWithRety(options, callback) { + tryRun(0, { + logger: options.logger, + retries: options.downloadRetries, + timeout: options.downloadRetryTimeout || defaultDownloadRetryTimeout + }, function (tryCallback) { + return download(options, tryCallback); + }, callback); +} + function downloadAndRun(options, callback) { if (arguments.length === 1) { callback = options; @@ -578,7 +610,7 @@ function downloadAndRun(options, callback) { logger = options.logger || function () {}; async.waterfall([ - async.apply(download, options), + async.apply(downloadWithRety, options), function (bin, next) { return run(bin, options, next); }, @@ -586,7 +618,7 @@ function downloadAndRun(options, callback) { } module.exports = downloadAndRun; -module.exports.download = download; +module.exports.download = downloadWithRety; module.exports.kill = killProcesses; module.exports.clean = clean; module.exports.setWorkDir = setWorkDir; diff --git a/lib/try_run.js b/lib/try_run.js index 2e33960..573be10 100644 --- a/lib/try_run.js +++ b/lib/try_run.js @@ -1,12 +1,10 @@ -var defaultConnectRetryTimeout = 2000; - -module.exports = function tryRun(count, options, fn, callback) { +module.exports = function tryRun(count, retryOptions, fn, callback) { fn(function (err, result) { if (err) { - if (count < options.connectRetries) { + if (count < retryOptions.retries) { return setTimeout(function () { - tryRun(count + 1, options, fn, callback); - }, options.connectRetryTimeout || defaultConnectRetryTimeout); + tryRun(count + 1, retryOptions, fn, callback); + }, retryOptions.timeout); } return callback(err); diff --git a/test/process_options.test.js b/test/process_options.test.js index 4477788..70d0aa8 100644 --- a/test/process_options.test.js +++ b/test/process_options.test.js @@ -67,6 +67,8 @@ describe("processOptions", function () { logger: function () {}, connectRetries: 1, connectRetryTimeout: 5000, + downloadRetries: 1, + downloadRetryTimeout: 1000, detached: true, connectVersion: "1.2.3" })).to.eql([]); diff --git a/test/sauce-connect-launcher.test.js b/test/sauce-connect-launcher.test.js index 0ddf6d1..df115af 100644 --- a/test/sauce-connect-launcher.test.js +++ b/test/sauce-connect-launcher.test.js @@ -24,6 +24,7 @@ try { sauceCreds.log.push(message); }; sauceCreds.connectRetries = 3; + sauceCreds.downloadRetries = 2; } catch (e) { require("colors"); console.log("Please run make setup-sauce to set up real Sauce Labs Credentials".red); @@ -63,6 +64,7 @@ describe("Sauce Connect Launcher", function () { it("fails with an invalid executable", function (done) { var options = _.clone(sauceCreds); options.exe = "not-found"; + options.connectRetries = 0; sauceConnectLauncher(options, function (err) { expect(err).to.be.ok(); @@ -74,6 +76,7 @@ describe("Sauce Connect Launcher", function () { it("does not trigger a download when providing a custom executable", function (done) { var options = _.clone(sauceCreds); options.exe = "not-found"; + options.connectRetries = 0; sauceConnectLauncher(options, function () { expect(fs.existsSync(path.join(__dirname, "../sc/versions.json"))).not.to.be.ok(); @@ -84,14 +87,15 @@ describe("Sauce Connect Launcher", function () { it("should download Sauce Connect", function (done) { // We need to allow enough time for downloading Sauce Connect var log = []; - sauceConnectLauncher.download({ - logger: function (message) { - if (verbose) { - console.log("[info] ", message); - } - log.push(message); - }, - }, function (err) { + var options = _.clone(sauceCreds); + options.logger = function (message) { + if (verbose) { + console.log("[info] ", message); + } + log.push(message); + }; + + sauceConnectLauncher.download(options, function (err) { expect(err).to.not.be.ok(); // Expected command sequence @@ -113,6 +117,75 @@ describe("Sauce Connect Launcher", function () { }); }); + it("handles errors when Sauce Connect download fails", function (done) { + var log = []; + var options = _.clone(sauceCreds); + options.logger = function (message) { + if (verbose) { + console.log("[info] ", message); + } + log.push(message); + }; + options.connectVersion = "9.9.9"; + options.downloadRetries = 1; + + sauceConnectLauncher.download(options, function (err) { + expect(err).to.be.ok(); + expect(err.message).to.contain("Download failed with status code"); + + // Expected command sequence + var expectedSequence = [ + "Missing Sauce Connect local proxy, downloading dependency", + "This will only happen once.", + "Invalid response status: 404", + "Missing Sauce Connect local proxy, downloading dependency", + "This will only happen once." + ]; + + _.each(log, function (message, i) { + expect(message).to.match(new RegExp("^" + (expectedSequence[i] || "\\*missing\\*"))); + }); + + done(); + }); + }); + + describe("handles misconfigured proxies and other request failures", function () { + let options, http_proxy_original; + + beforeEach(function () { + options = _.clone(sauceCreds); + options.downloadRetries = 0; + + http_proxy_original = process.env.http_proxy; + process.env.http_proxy = "http://127.0.0.1:12345/"; + }) + + afterEach(function () { + process.env.http_proxy = http_proxy_original; + }) + + it("when fetching versions.json", function (done) { + sauceConnectLauncher.download(options, function (err) { + expect(err).to.be.ok(); + expect(err.message).to.contain("ECONNREFUSED"); + + done(); + }); + }); + + it("with fixed version when fetching archive", function (done) { + options.connectVersion = "9.9.9"; + + sauceConnectLauncher.download(options, function (err) { + expect(err).to.be.ok(); + expect(err.message).to.contain("ECONNREFUSED"); + + done(); + }); + }); + }); + if (sauceCreds) { it("should work with real credentials", function (done) { sauceConnectLauncher(sauceCreds, function (err, sauceConnectProcess) { diff --git a/test/try_run_test.js b/test/try_run_test.js index 85750bf..d5002b3 100644 --- a/test/try_run_test.js +++ b/test/try_run_test.js @@ -19,10 +19,9 @@ describe("tryRun", function () { }); describe("with configured retry", function () { - var retryTimeout = 10; var retryOptions = { - connectRetries: 2, - connectRetryTimeout: retryTimeout + retries: 2, + timeout: 10 }; it("calls the provided function once when no error is returned", function (done) { @@ -49,7 +48,7 @@ describe("tryRun", function () { }); }); - it("waits connectRetryTimeout between retries", function (done) { + it("waits timeout between retries", function (done) { var start = Date.now(); tryRun(0, retryOptions, function (callback) { callback(innerErr);