diff --git a/bin/zuul b/bin/zuul index 2ebf65c..97629df 100755 --- a/bin/zuul +++ b/bin/zuul @@ -22,6 +22,7 @@ program .option('--local [port]', 'port for manual testing in a local browser') .option('--tunnel', 'establish a tunnel for outside access. only used when --local is specified') .option('--phantom', 'run tests in phantomjs. PhantomJS must be installed separately.') +.option('--browserstack', 'run tests in BrowserStack instead of Sauce Labs.') .option('--tunnel-host ', 'specify a localtunnel server to use for forwarding') .option('--sauce-connect [tunnel-identifier]', 'use saucelabs with sauce connect instead of localtunnel. Optionally specify the tunnel-identifier') .option('--server ', 'specify a server script to be run') @@ -39,6 +40,7 @@ var config = { ui: program.ui, tunnel: program.tunnel, phantom: program.phantom, + selenium_runner: program.browserstack ? 'BrowserStack' : 'SauceLabs', prj_dir: process.cwd(), tunnel_host: program.tunnelHost, sauce_connect: program.sauceConnect, @@ -67,10 +69,23 @@ if(!process.stdout.isTTY){ }); } +// optional additional local config or from $HOME/.zuulrc +var local_config = find_nearest_file('.zuulrc') || path.join(osenv.home(), '.zuulrc'); +if (fs.existsSync(local_config)) { + var zuulrc = yaml.parse(fs.readFileSync(local_config, 'utf-8')); + config = xtend(zuulrc, config); + config.tunnel_host = config.tunnel_host || zuulrc.tunnel_host; +} + +config.username = {'SauceLabs': process.env.SAUCE_USERNAME || config.sauce_username, + 'BrowserStack': process.env.BROWSERSTACK_USERNAME || config.browserstack_username}[config.selenium_runner] +config.key = {'SauceLabs': process.env.SAUCE_ACCESS_KEY || config.sauce_key, + 'BrowserStack': process.env.BROWSERSTACK_ACCESS_KEY || config.browserstack_key}[config.selenium_runner] + if (program.listAvailableBrowsers) { - scout_browser(function(err, all_browsers) { + scout_browser(config, function(err, all_browsers) { if (err) { - console.error('Unable to get available browsers for saucelabs'.red); + console.error('Unable to get available browsers for '+config.selenium_runner+''.red); console.error(err.stack); return process.exit(1); } @@ -106,20 +121,6 @@ if (program.browserName) { config = xtend(config, { browsers: [{ name: program.browserName, version: program.browserVersion, platform: program.browserPlatform }] }); } -// optional additional local config or from $HOME/.zuulrc -var local_config = find_nearest_file('.zuulrc') || path.join(osenv.home(), '.zuulrc'); -if (fs.existsSync(local_config)) { - var zuulrc = yaml.parse(fs.readFileSync(local_config, 'utf-8')); - config = xtend(zuulrc, config); - config.tunnel_host = config.tunnel_host || zuulrc.tunnel_host; -} - -var sauce_username = process.env.SAUCE_USERNAME; -var sauce_key = process.env.SAUCE_ACCESS_KEY; - -config.username = sauce_username || config.sauce_username; -config.key = sauce_key || config.sauce_key; - if (!config.ui) { console.error('Error: `ui` must be configured in .zuul.yml or specified with the --ui flag'); return process.exit(1); @@ -155,15 +156,15 @@ else if (config.phantom) { if (!config.username || !config.key) { console.error('Error:'); - console.error('Zuul tried to run tests in saucelabs, however no saucelabs credentials were provided.'); + console.error('Zuul tried to run tests in '+config.selenium_runner+', however no '+config.selenium_runner+' credentials were provided.'); console.error('See the zuul wiki (https://github.com/defunctzombie/zuul/wiki/Cloud-testing) for info on how to setup cloud testing.'); process.exit(1); return; } -scout_browser(function(err, all_browsers) { +scout_browser(config, function(err, all_browsers) { if (err) { - console.error('Unable to get available browsers for saucelabs'.red); + console.error('Unable to get available browsers for '+config.selenium_runner+''.red); console.error(err.stack); return process.exit(1); } diff --git a/examples/quickstart/.zuul.yml b/examples/quickstart/.zuul.yml index 5c48d0a..7c5f3ff 100644 --- a/examples/quickstart/.zuul.yml +++ b/examples/quickstart/.zuul.yml @@ -2,7 +2,6 @@ name: quickstart ui: mocha-qunit browsers: - name: firefox - platform: linux version: 30..latest - name: chrome version: 30..beta diff --git a/lib/BrowserStackBrowser.js b/lib/BrowserStackBrowser.js new file mode 100644 index 0000000..f9ecda2 --- /dev/null +++ b/lib/BrowserStackBrowser.js @@ -0,0 +1,236 @@ +var wd = require('wd'); +var EventEmitter = require('events').EventEmitter; +var FirefoxProfile = require('firefox-profile'); +var debug = require('debug')('zuul:browserstackbrowser'); +var xtend = require('xtend'); + +var setup_test_instance = require('./setup'); + +function BrowserStackBrowser(conf, opt) { + if (!(this instanceof BrowserStackBrowser)) { + return new BrowserStackBrowser(conf, opt); + } + + var self = this; + self._conf = conf; + self._opt = opt; + self._opt.tunnel = !opt.sauce_connect; // TODO: BrowserStack local testing + self.stats = { + passed: 0, + failed: 0 + }; +} + +BrowserStackBrowser.prototype.__proto__ = EventEmitter.prototype; + +BrowserStackBrowser.prototype.toString = function() { + var self = this; + var conf = self._conf; + return '<' + conf.browser + ' ' + conf.version + ' on ' + conf.platform + '>'; +}; + +BrowserStackBrowser.prototype.start = function() { + var self = this; + var conf = self._conf; + + self.stopped = false; + self.stats = { + passed: 0, + failed: 0 + }; + + debug('running %s %s %s', conf.browser, conf.version, conf.platform); + var browser = self.browser = wd.remote('hub.browserstack.com', 80, conf.username, conf.key); + + browser.configureHttp({ + timeout: undefined, + retries: 1, + retryDelay: 1000 + }); + + self.controller = setup_test_instance(self._opt, function(err, url) { + if (err) { + return self.shutdown(err); + } + + self.emit('init', conf); + + var init_conf = xtend({ + build: conf.build, + name: conf.name, + tags: conf.tags || [], + browserName: conf.browser, + version: conf.version, + platform: 'ANY' + }, conf.capabilities); + + if (conf.firefox_profile) { + var fp = new FirefoxProfile(); + var extensions = conf.firefox_profile.extensions; + for (var preference in conf.firefox_profile) { + if (preference !== 'extensions') { + fp.setPreference(preference, conf.firefox_profile[preference]); + } + } + extensions = extensions ? extensions : []; + fp.addExtensions(extensions, function () { + fp.encoded(function(zippedProfile) { + init_conf.firefox_profile = zippedProfile; + init(); + }); + }); + } else { + init(); + } + + function init() { + debug('queuing %s %s %s', conf.browser, conf.version, conf.platform); + + browser.init(init_conf, function(err) { + if (err) { + if (err.data) { + err.message += ': ' + err.data.split('\n').slice(0, 1); + } + return self.shutdown(err); + } + + var reporter = new EventEmitter(); + + reporter.on('test_end', function(test) { + if (!test.passed) { + return self.stats.failed++; + } + self.stats.passed++; + }); + + reporter.on('done', function(results) { + debug('done %s %s %s', conf.browser, conf.version, conf.platform); + var passed = results.passed; + var called = false; + browser.sauceJobStatus(passed, function(err) { + if (called) { + return; + } + + called = true; + self.shutdown(); + + if (err) { + return; + // don't let this error fail us + } + }); + + reporter.removeAllListeners(); + }); + + debug('open %s', url); + self.emit('start', reporter); + + var timeout = false; + var get_timeout = setTimeout(function() { + debug('timed out waiting for open %s', url); + timeout = true; + self.shutdown(new Error('Timeout opening url')); + }, 60 * 1000); + + browser.get(url, function(err) { + if (timeout) { + return; + } + + clearTimeout(get_timeout); + if (err) { + return self.shutdown(err); + } + + (function wait() { + if (self.stopped) { + return; + } + + debug('waiting for test results from %s', url); + var js = '(window.zuul_msg_bus ? window.zuul_msg_bus.splice(0, 10) : []);' + browser.eval(js, function(err, res) { + if (err) { + debug('err: %s', err.message); + } + + debug('res.length: %s', res.length); + + if (err) { + return self.shutdown(err); + } + + var has_done = false; + res = res || []; + res.filter(Boolean).forEach(function(msg) { + if (msg.type === 'done') { + has_done = true; + } + + reporter.emit(msg.type, msg); + }); + + if (has_done) { + debug('finished tests for %s', url); + return; + } + + debug('fetching more results'); + setTimeout(wait, 1000); + }); + })(); + }); + }); + } + }); +}; + +BrowserStackBrowser.prototype.shutdown = function(err) { + var self = this; + + self.stopped = true; + + finish_shutdown = function() { + debug('shutdown'); + + if (self.controller) { + try { self.controller.shutdown(); } catch (e) {} + } + + if (err) { + self.emit('error', err); + return; + } + + self.emit('done', self.stats); + self.removeAllListeners(); + } + + // make sure the browser shuts down before continuing + if (self.browser) { + debug('quitting browser'); + + var timeout = false; + var quit_timeout = setTimeout(function() { + debug('timed out waiting for browser to quit'); + timeout = true; + finish_shutdown(); + }, 10 * 1000); + + self.browser.quit(function(err) { + if (timeout) { + return; + } + + clearTimeout(quit_timeout); + finish_shutdown(); + }); + } + else { + finish_shutdown(); + } +}; + +module.exports = BrowserStackBrowser; diff --git a/lib/flatten_browser.js b/lib/flatten_browser.js index b552711..52fed80 100644 --- a/lib/flatten_browser.js +++ b/lib/flatten_browser.js @@ -40,6 +40,8 @@ function flatten(request, all_browsers) { return a.version - b.version; }); + var beta_available = avail[avail.length - 1].version === 'beta'; + // remove duplicate version entries // because we are not interested in testing on all platforms avail.reduce(function(prev, curr, idx, arr) { @@ -68,6 +70,11 @@ function flatten(request, all_browsers) { // or ##..latest function process_version_str(version) { version = String(version); + + if (version == 'beta' && !beta_available) { + console.log('Couldnt find beta version, using latest.'); + version = 'latest'; + } if (version === 'latest') { return get_numeric_versions(avail).slice(-1).map(addProfile); } @@ -94,6 +101,10 @@ function flatten(request, all_browsers) { start_idx = v_map.indexOf(start); } + if (end == 'beta' && !beta_available) { + console.log('Couldnt find beta version, using latest.'); + end = 'latest'; + } if (end === 'latest') { end_idx = get_numeric_versions(avail).length - 1; } diff --git a/lib/scout_browser.js b/lib/scout_browser.js index c7babd7..1cd7c5d 100644 --- a/lib/scout_browser.js +++ b/lib/scout_browser.js @@ -9,13 +9,37 @@ // ] // } -var https = require('https'); +var https = require('https') +, debug = require('debug')('zuul:scout_browser'); -module.exports = function(cb) { - var info_opt = { +var http_endpoints = { + 'SauceLabs': function () { return { host: 'saucelabs.com', path: '/rest/v1/info/browsers/webdriver' - }; + }; }, + 'BrowserStack': function (config) { + throwOnNoUsernameOrKey(config); + return { + host: 'www.browserstack.com', + path: '/automate/browsers.json', + auth: config.username + ':' + config.key + }; + } +} + +var formatters = { + 'SauceLabs': formatSauceLabs, + 'BrowserStack': formatBrowserStack +} + +module.exports = function(config, cb) { + if (!cb) cb = config, config = null; + + var provider = config.selenium_runner || 'SauceLabs' + , info_opt = http_endpoints[provider](config) + , format = formatters[provider]; + + debug('requesting browsers with opts:', info_opt) https.get(info_opt, function(res) { res.setEncoding('utf8'); @@ -27,6 +51,7 @@ module.exports = function(cb) { res.once('end', function() { try { + debug('got body', body.slice(0, 100), '...') var formatted = format(JSON.parse(body)); } catch (err) { return cb(err); @@ -39,7 +64,7 @@ module.exports = function(cb) { }); }; -function format(obj) { +function formatSauceLabs(obj) { var browsers = {}; obj.forEach(function(info) { var name = info.api_name; @@ -54,3 +79,29 @@ function format(obj) { return browsers; } + +function formatBrowserStack(obj) { + var browsers = {}; + obj.forEach(function(info) { + var name = info.browser; + + var browser = browsers[name] = browsers[name] || []; + browser.push({ + name: name, + version: parseInt(info.browser_version || info.os_version).toString(), + platform: info.device || (info.os + ' ' + info.os_version) + }); + }); + + return browsers; +} + +function throwOnNoUsernameOrKey(config) { + if (!config.username || !config.key) { + console.error('Error:'); + console.error('Zuul tried to query browsers from '+config.selenium_runner+', however no '+config.selenium_runner+' credentials were provided.'); + console.error('See the zuul wiki (https://github.com/defunctzombie/zuul/wiki/Cloud-testing) for info on how to setup cloud testing.'); + process.exit(1); + return; + } +} \ No newline at end of file diff --git a/lib/zuul.js b/lib/zuul.js index 0ac9e9f..0d55bf9 100644 --- a/lib/zuul.js +++ b/lib/zuul.js @@ -7,6 +7,7 @@ var control_app = require('./control-app'); var frameworks = require('../frameworks'); var setup_test_instance = require('./setup'); var SauceBrowser = require('./SauceBrowser'); +var BrowserStackBrowser = require('./BrowserStackBrowser'); var PhantomBrowser = require('./PhantomBrowser'); module.exports = Zuul; @@ -31,6 +32,11 @@ function Zuul(config) { self._browsers = []; self._concurrency = config.concurrency || 3; + + self._Browser = { + 'SauceLabs': SauceBrowser, + 'BrowserStack': BrowserStackBrowser + }[config.selenium_runner] } Zuul.prototype.__proto__ = EventEmitter.prototype; @@ -51,7 +57,7 @@ Zuul.prototype.browser = function(info) { var self = this; var config = self._config; - self._browsers.push(SauceBrowser({ + self._browsers.push(self._Browser({ name: config.name, build: process.env.TRAVIS_BUILD_NUMBER, firefox_profile: info.firefox_profile, diff --git a/package.json b/package.json index a1f39ea..a1dd259 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "batch": "0.5.0", "bouncy": "3.2.2", "browserify": "6.3.3", + "browserstack-webdriver": "^2.41.1", "char-split": "0.2.0", "colors": "0.6.2", "commander": "2.1.0", @@ -61,4 +62,4 @@ "scripts": { "test": "DEBUG=zuul* mocha --ui qunit --timeout 0 --bail -- test/index.js" } -} \ No newline at end of file +}