var request = require( "request" ), querystring = require( "querystring" ).stringify, url = require( "url" ), util = require( "util" ), events = require( "events" ); function extend( a, b ) { for ( var key in b ) { a[key] = b[key]; } return a; } /** * Utility for polling the API. * @param {Object} params * - {number} interval * - {number} timeout * - {Function} makeRequest * - {Function} doContinue * - {Function} onSuccess [optional] Called when continue returns false. * - {Function} onTimeout [optional] Called if continue returns true after timeout is reached. */ function pollJSON( params ) { var tsStart = Date.now(); params = extend({ onSuccess: function() {}, onTimeout: function() {} }, params ); function handleRequest( err, resp, body ) { var data; if ( err ) { throw err; } try { data = JSON.parse( body ); } catch ( e ) { throw e; } if ( params.doContinue( data ) ) { if ( tsStart + params.timeout < Date.now() ) { params.onTimeout( data ); } else { setTimeout( function() { params.makeRequest( handleRequest ); }, params.interval ); } } else { params.onSuccess( data ); } } params.makeRequest( handleRequest ); } function isJobDone( job ) { return !job.runs.filter(function( run ) { return Object.keys(run.uaRuns).filter(function( uaRun ) { return (/new|progress/).test( run.uaRuns[ uaRun ].runStatus ); }).length; }).length; } /** * @param {TestSwarm} testswarm * @param {Object} options From TestSwarm..addjob. * @param {Object} jobInfo * @param {Function( {mixed} err, {Object} job )} callback */ function pollJobStatus( testswarm, options, jobInfo, callback ) { var config = testswarm.config, i = 1; pollJSON({ interval: options.pollInterval, timeout: options.timeout, makeRequest: function( handleRequest ) { request.get( config.url + "/api.php?" + querystring({ action: "job", item: jobInfo.id }), handleRequest); }, doContinue: function( data ) { if ( !data.job ) { throw new Error( "Invalid API response, can't continue. Response was: " + data ); } testswarm.emit( "addjob-poll", ++i ); return !isJobDone( data.job ); }, onTimeout: function( data ) { callback( "Timed out after " + options.timeout + "ms", data.job ); }, onSuccess: function( data ) { callback( null, data.job ); } }); } /** * Get url to root of testswarm server (without the path to the installation, if any). * * @param {TestSwarm} testswarm * @return {string} Domain root of url. */ function getServerRoot( testswarm ) { var obj = url.parse( testswarm.config.url ); obj.href = obj.pathname = obj.search = obj.path = obj.query = obj.hash = undefined; // Normalise url to no trailing slash return url.format( obj ).replace( /\/$/, "" ); } /** * Add authentication parameters to request data. * * @param {TestSwarm} testswarm * @param {Object} data * @return {Object} data */ function authenticateRequest( testswarm, data ) { if ( !testswarm.auth ) { throw new Error( "Authentication required, call .auth() first." ); } data.authID = testswarm.auth.id; data.authToken = testswarm.auth.token; return data; } function keysMissingFrom( obj, keys ) { return keys.filter(function( val ) { if ( obj[ val ] === undefined ) { return true; } return false; }); } /** * @constructor * @extends EventEmitter * @param {Object} config * - {string} url Url to root of TestSwarm install. * @emits {string} log Regular output regarding progress of performed actions. * @emits {string} verbose Verbose output for debugging. */ function TestSwarm( config ) { var missing = keysMissingFrom( config, [ "url" ] ); if ( missing.length ) { throw new Error( "Invalid configuration, missing key '" + missing[0] + "'." ); } // Normalise url to no trailing slash config.url = url.format( url.parse( config.url ) ).replace( /\/$/, "" ); this.config = config; } util.inherits( TestSwarm, events.EventEmitter ); TestSwarm.prototype.addReporter = function( reporter ) { reporter( this ); return this; }; /** * @param {Object} auth * - {string} id Username of TestSwarm account. * - {string} token Authentication token of account. */ TestSwarm.prototype.auth = function( auth ) { var missing = keysMissingFrom( auth, [ "id", "token" ] ); if ( missing.length ) { throw new Error( "Invalid authentication, missing key '" + missing[0] + "'." ); } this.auth = auth; return this; }; /** * @method * @param {Object} options * - {string} name * - {number} runMax [optional] * - {Object} runs Run urls by run name. * - {Array|string} browserSets * - {number} pollInterval [optional] In milliseconds. * - {number} timeout [optional] In milliseconds. * @param {Function( {mixed} err, {boolean} passed, {Object} results )} callback * @emits addjob-created ( {Object} jobInfo ) * @emits addjob-poll ( {number} iteration ) Starts with 1. * @emits addjob-ready ( {boolean} passsed, {Object} results ) */ TestSwarm.prototype.addjob = function( options, callback ) { var testswarm, missing, runNames, runUrls, browserSets; testswarm = this; missing = keysMissingFrom( options, [ "name", "runs", "browserSets" ] ); if ( missing.length ) { callback( "Invalid options, missing key '" + missing[0] + "'." ); return; } options = extend({ runMax: 2, pollInterval: 5000, // 5 seconds timeout: 15 * 60 * 1000 // 15 minutes }, options); runNames = Object.keys( options.runs ); runUrls = runNames.slice().map(function( name ) { return options.runs[name]; }); browserSets = Array.isArray( options.browserSets ) ? options.browserSets : [ options.browserSets ]; request.post({ url: testswarm.config.url + "/api.php", form: authenticateRequest( testswarm, { action: "addjob", jobName: options.name, runMax: options.runMax, runNames: runNames, runUrls: runUrls, browserSets: browserSets }) }, function( err, resp, body ) { var data, jobInfo; if ( err ) { callback( err, false ); return; } try { data = JSON.parse( body ); } catch ( e ) { callback( "Failed parsing body as json, was: " + body, false ); } if ( !data.addjob ) { callback( "API returned error, can't continue. Response was: " + body, false ); return; } jobInfo = data.addjob; testswarm.emit( "log", "Submitted job " + jobInfo.id + " " + testswarm.config.url + "/job/" + jobInfo.id ); testswarm.emit( "log", "Polling for results" ); testswarm.emit( "verbose", JSON.stringify( jobInfo ) ); testswarm.emit( "addjob-created", jobInfo ); pollJobStatus( testswarm, options, jobInfo, function( err, job ) { var passed = true, results = {}; function runStats( uaID, uaRun ) { var val = uaRun.runStatus; if ( uaRun.runResultsUrl ) { val += " (" + uaRun.runResultsLabel + "). " + getServerRoot( testswarm ) + uaRun.runResultsUrl; } return val; } job.runs.filter(function( run ) { results[run.info.name] = {}; for ( var uaID in run.uaRuns ) { results[run.info.name][uaID] = runStats( uaID, run.uaRuns[uaID] ); if ( passed && run.uaRuns[uaID].runStatus !== "passed" ) { // "new", "failed", "error", "timedout", .. passed = false; } } }); testswarm.emit( "addjob-ready", err, passed, results ); callback( err, passed, results ); } ); }); }; module.exports = { createClient: function( config ) { return new TestSwarm( config ); }, reporters: require( "./reporters" ) };