UNPKG

duo-test

Version:

Duo's testing utility.

489 lines (414 loc) 9.69 kB
/** * Module dependencies. */ var Emitter = require('events').EventEmitter; var debug = require('debug')('duo-test'); var localtunnel = require('localtunnel'); var basename = require('path').basename; var PhantomJS = require('./phantomjs'); var read = require('fs').readFileSync; var exists = require('co-fs').exists; var thunkify = require('thunkify'); var serve = require('koa-static'); var fmt = require('util').format; var Remote = require('./remote'); var join = require('path').join; var assert = require('assert'); var exec = require('co-exec'); var _ = require('koa-route'); var http = require('http'); var koa = require('koa'); /** * Assets path. */ var path = join(__dirname, '..', 'client'); /** * Assets */ var assets = { duotest: read(join(path, 'build.js'), 'utf-8'), mochajs: read(require.resolve('mocha/mocha.js'), 'utf-8'), mochacss: read(require.resolve('mocha/mocha.css'), 'utf-8'), template: require(join(path, 'default.js')) }; /** * Expose `DuoTest`. */ module.exports = DuoTest; /** * Initialize `DuoTest`. * * @param {String} root * @param {Object} opts * @api public */ function DuoTest(root){ if (!(this instanceof DuoTest)) return new DuoTest(root); assert(root, 'root module path must be given'); Emitter.call(this); this.stdout = process.stdout; this.stderr = process.stderr; this.title(basename(root)); this.pathname('/test'); this.build('/build.js'); this.root = root; this.app = koa(); this.app.use(serve(this.root, { defer: true })); this.browsers = {}; this.adapters = []; this.adapter(Remote.adapter.regexp, Remote.adapter); this.adapter(PhantomJS.adapter.regexp, PhantomJS); this.app.use(_.get('/duotest', this.event())); this.app.use(_.get('/duotest.js', this.send())); } /** * Inherit `Emitter`. */ DuoTest.prototype.__proto__ = Emitter.prototype; /** * Add browser adapter with `regexp` and `fn`. * * @param {RegExp} regexp * @param {Function} fn * @api public */ DuoTest.prototype.adapter = function(regexp, fn){ this.adapters.push([regexp, fn]); return this; }; /** * Set the build path. * * @param {String} path * @return {DuoTest} * @api public */ DuoTest.prototype.build = function(path){ if (0 == arguments.length) return this._build; if ('/' != path[0]) path = '/' + path; this._build = path; return this; }; /** * Add a browser `type:name`. * * Examples: * * add('saucelabs:chrome:35..'); * add('saucelabs:iphone:stable'); * add('saucelabs:chrome:..stable'); * add('phantomjs', opts) * * @param {String} name * @param {Object} opts * @return {DuoTest} * @api public */ DuoTest.prototype.add = function(name, opts){ var all = this.adapters; var opts = opts || {}; var browsers = []; for (var i = 0, a; a = all[i++];) { var regexp = a[0]; var fn = a[1]; var m; if (m = regexp.exec(name)) { var b = fn(this, opts, m[1]); if (!Array.isArray(b)) b = [b]; browsers.push.apply(browsers, b); } } for (var i = 0; i < browsers.length; ++i) { var b = browsers[i]; this.browsers[b.id] = b; } return this; }; /** * Set auth `user`, `key`. * * @param {String} user * @param {String} key * @return {Object|DuoTest} * @api public */ DuoTest.prototype.auth = function(user, key){ if (0 == arguments.length) return this._auth; this._auth = { user: user, key: key }; return this; }; /** * Set your test title. * * This will be used in the default.html (if used) * and will appear in saucelabs UI. * * @param {String} title * @return {String|DuoTest} * @api public */ DuoTest.prototype.title = function(title){ if (0 == arguments.length) return this._title; this._title = title; return this; }; /** * Set your tests path. * * This will be used in the app, for example * if your directory structure is: * * - module * - index.js * - tests * - test.js * - index.html * * The path should be `/tests` since the app * is started from ./module and not ./tests * * @param {String} pathname * @return {String} * @api public */ DuoTest.prototype.pathname = function(pathname){ if (0 == arguments.length) return this._pathname; pathname = normalize(pathname); this._pathname = pathname; return this; }; /** * Get the url with optional `clientId` to attach. * * @param {String} id * @return {String} * @api private */ DuoTest.prototype.url = function(id){ var url = this.tunnel ? fmt('%s%s', this.tunnel.url, this.pathname()) : fmt('http://localhost:%s%s', this.address.port, this.pathname()); if (1 == arguments.length) { url += fmt('%s__id__=%s' , ~url.indexOf('?') ? '&' : '?' , id); } return url; }; /** * Expose the app using localtunnel. * * @return {DuoTest} * @api public */ DuoTest.prototype.expose = function(){ var self = this; return function(done){ var port = self.address.port; localtunnel(port, function(err, tunnel){ if (err) return done(err); self.tunnel = tunnel; debug('localtunnel %s', tunnel.url); done(null, self); }); }; }; /** * Execute `cmd` as middleware. * * @param {String} cmd * @return {DuoTest} * @api private */ DuoTest.prototype.command = function(cmd){ var root = this.root; var app = this.app; var self = this; app.use(function* command(next){ if (this.path == self.pathname()) { debug('exec %s', cmd); yield exec(cmd, { cwd: root }); debug('executed %s', cmd); } yield next; }); return this; }; /** * Listen on `port`. * * TODO: serve default html if custom one is not found. * * @return {DuoTest} * @api public */ DuoTest.prototype.listen = function*(port){ var index = join(this.root, this.pathname(), 'index.html'); var port = port || 0; var app = this.app; var self = this; // when `test-path/index.html` // is missing serve the default // assets if (!(yield exists(index))) { debug('index.html not found serving default.html'); var html = assets.template({ opts: JSON.stringify({ ui: 'bdd' }), title: this.title(), build: this.build() }); app.use(_.get(this.pathname(), serve('html', html))); app.use(_.get('/mocha.js', serve('js', assets.mochajs))); app.use(_.get('/mocha.css', serve('css', assets.mochacss))); } // serve type, body. function serve(type, body){ return function*(){ this.type = type; this.body = body; }; } // server var server = http.createServer(this.app.callback()); // listen yield function(done){ server.listen(port, function(err){ if (err) return done(err); self.server = server; self.address = server.address(); debug('started localhost:%s', self.address.port); done(); }); }; return this; }; /** * Destroy. * * @api public */ DuoTest.prototype.destroy = function*(){ var browsers = this.browsers; var keys = Object.keys(browsers); // browsers yield keys.map(function(k){ return browsers[k].quit(); }); // server if (this.server) { this.server.close(); } // tunnel if (this.tunnel) { this.tunnel.close(); } this.address = null; this.server = null; this.tunnel = null; this.app = null; debug('destroyed'); return this; }; /** * Run test on all browsers. * * @return {DuoTest} * @api public */ DuoTest.prototype.run = function*(){ var all = this.browsers; var keys = Object.keys(all); var self = this; debug('test on %d browsers', keys.length); yield keys.map(function(k){ return function*(){ var browser = all[k]; var runner = browser.runner; var url = self.url(browser.id); yield browser.connect(); self.emit('browser', browser); try { yield [end, browser.get(url)]; yield browser.quit(); delete self.browsers[browser.id]; } catch (e) { yield browser.quit(); delete self.browsers[browser.id]; throw e; } function end(done){ runner.once('end', function(){ setImmediate(done); }); } }; }); debug('tested on %d browsers', keys.length); return this; }; /** * Send duotest(). * * @return {Function} * @api private */ DuoTest.prototype.send = function(){ var self = this; return function*(){ this.type = 'js'; this.body = self.adapters.length ? assets.duotest : 'duotest = function(){};'; debug('sent duotest()'); }; }; /** * Receive an event from `duotest()`. * * @return {Function} * @api private */ DuoTest.prototype.event = function(){ var self = this; return function*(){ var data = decodeURIComponent(this.query.data); var id = this.query.id; var b = self.browsers[id]; var j = JSON.parse(data); // edge-case if (!b) return; // HACK if (j.data) { j.data.slow = Function('return this._slow'); j.data.fullTitle = Function('return this._fullTitle'); } // runner var runner = b.runner; // HACK if (!runner.emittedStart) { runner.emittedStart = true; runner.emit('start'); } // emit debug('emit %s %j', j.event, j.data); runner.emit(j.event, j.data, j.data.err); // callback var fn = this.query.callback; var js = fmt('(this.%s && this.%s());', fn, fn); this.type = 'js'; this.body = js; debug('sent %s', js); }; }; /** * Normalize `path`. * * @param {String} path * @return {String} * @api private */ function normalize(path){ if ('/' != path[0]) path = '/' + path; if ('/' != path.slice(-1)) path += '/'; return path; }