yeti
Version:
369 lines (306 loc) • 9.56 kB
JavaScript
/**
* Yeti Hub Client.
* @module client
*/
"use strict";
var fs = require("graceful-fs");
var path = require("path");
var util = require("util");
var shallowCopy = require("./shallow-copy");
var EventEmitter2 = require("eventemitter2").EventEmitter2;
var FileMatcher = require("./file-matcher");
var Blizzard = require("./blizzard");
/**
* The ClientBatch represents a batch on the Hub.
*
* @class ClientBatch
* @constructor
* @extends EventEmitter2
* @param {BlizzardSession} session Connected Blizzard session to the Hub.
* @param {Object} options Options (see `Client.createBatch()`).
*/
function ClientBatch(session, options) {
var basedir = options.basedir,
tests = options.tests,
istanbul;
if (!basedir && options.useProxy) {
throw new Error("Basedir required.");
}
if (basedir && (basedir !== path.resolve(basedir))) {
throw new Error("Basedir is not an absolute path!");
}
if (!Array.isArray(tests)) {
throw new Error("Array of tests required.");
}
EventEmitter2.call(this);
this.session = session;
this.basedir = basedir;
this.tests = tests;
if (options.instrument) {
try {
istanbul = require("istanbul");
} catch (ex) {
throw new Error("Coverage requested but unable to load Istanbul");
}
this.instrumenter = new istanbul.Instrumenter();
this.coverageMatcher = new FileMatcher({
extension: "js",
excludes: options.coverageExcludes
});
}
this.id = null;
this.batchSession = null;
// Setup our events.
this.once("ack", this.onAck.bind(this));
// Remove options.basedir before sending to the server.
options = shallowCopy(options);
delete options.basedir;
this.session.emit("rpc.batch", options, this.emit.bind(this, "ack"));
}
util.inherits(ClientBatch, EventEmitter2);
/**
* Something went wrong.
* @event error
* @param {Error} error The error object.
*/
/**
* The batch is complete. Browsers may be shutting down.
* @event complete
*/
/**
* The batch has finished, browsers have shut down.
* No more events will be fired.
* @event end
*/
/**
* The agent given has completed testing.
* @event agentComplete
* @param {String} agent Agent name.
*/
/**
* Test result from an agent.
* @event agentResult
* @param {String} agent Agent name.
* @param {Object} details Test result details, in YUI Test JSON format.
* @param {Object} [details.coverage] Test coverage details, if YUI Test Coverage instrumented code was present.
*/
/**
* Error while handling an agent request.
* @event agentError
* @param {String} agent Agent name.
* @param {Object} details Exception details.
* @param {String} details.message Exception message.
*/
/**
* Pedantic error while handling an agent request.
* @event agentPedanticError
* @param {String} agent Agent name.
* @param {Object} details Exception details.
* @param {String} details.message Exception message.
*/
/**
* Uncaught JavaScript error from an agent.
* @event agentScriptError
* @param {String} agent Agent name.
* @param {Object} details Exception details.
* @param {String} details.url Exception URL.
* @param {Number} details.line Exception line number.
* @param {String} details.message Exception message.
*/
/**
* @method onAck
* @protected
* @param {Error} err
* @param {Number} id
*/
ClientBatch.prototype.onAck = function (err, id) {
var self = this;
if (err) {
self.emit("error", err);
}
self.id = id;
self.batchSession = self.session.createNamespace("batch" + self.id);
self.batchSession.incomingBridge(self, "end");
self.batchSession.incomingBridge(self, "complete");
self.batchSession.incomingBridge(self, "dispatch");
self.batchSession.incomingBridge(self, "agentComplete");
self.batchSession.incomingBridge(self, "agentResult");
self.batchSession.incomingBridge(self, "agentBeat");
self.batchSession.incomingBridge(self, "agentProgress");
self.batchSession.incomingBridge(self, "agentError");
self.batchSession.incomingBridge(self, "agentPedanticError");
self.batchSession.incomingBridge(self, "agentScriptError");
self.once("end", function () {
self.batchSession.unbind();
});
self.provideTests();
};
/**
* Setup BlizzardNamespace event listener that will
* serve local test files under this.basedir.
*
* @method provideTests
* @protected
*/
ClientBatch.prototype.provideTests = function () {
var self = this;
self.batchSession.on("request.clientFile", function (args, reply) {
var file = args[0],
completer = reply;
if (!self.basedir) {
reply("Not permitted.");
return;
}
if (file[0] !== "/") {
// Path is relative, resolve it to an absolute path.
file = path.resolve(self.basedir, file);
}
// The file path must be inside the basedir.
if (file.indexOf(self.basedir) !== 0) {
reply("Filename provided is not in basedir!");
return;
}
// Instrument JS for code coverage
if (self.instrumenter && self.coverageMatcher.match(file) && fs.existsSync(file)) {
completer = function (err, data) {
try {
data = data.toString("utf8");
data = self.instrumenter.instrumentSync(data, file);
} catch (err) {
console.warn("[yeti] Unable to instrument file " + file + ": " + err);
}
return reply(null, new Buffer(data, "utf8"));
};
}
fs.readFile(file, completer);
});
};
/**
* The Client submits test batches to a Yeti Hub and tracks their progress.
*
* @class Client
* @constructor
* @extends EventEmitter2
* @param {String} url Yeti Hub HTTP URL.
*/
function Client(url) {
EventEmitter2.call(this);
this.session = null;
this.url = url;
this.blizzard = new Blizzard();
this.blizzard.on("error", this.handleBlizzardError.bind(this));
}
util.inherits(Client, EventEmitter2);
/**
* Something went wrong.
* @event error
* @param {Error} error The error object.
*/
/**
* An agent connected to the Hub.
* @event agentConnect
* @param {String} agent Agent name.
*/
/**
* An agent requested a page (test or capture) from the connected Hub.
* @event agentSeen
* @param {String} agent Agent name.
*/
/**
* An agent disconnected from the Hub.
* @event agentDisconnect
* @param {String} agent Agent name.
*/
/**
* Connect to the Yeti Hub.
*
* @method connect
* @param {Function} cb Callback function.
*/
Client.prototype.connect = function (cb) {
var self = this;
self.blizzard.connect(self.url, function (err, session) {
if (err) {
cb(err);
return;
}
session.incomingBridge(self, "agentConnect");
session.incomingBridge(self, "agentSeen");
session.incomingBridge(self, "agentDisconnect");
self.session = session;
cb(err);
});
};
/**
* Disconnect from the Yeti Hub.
*
* @method end
*/
Client.prototype.end = function () {
if (!this.session) {
throw new Error("Session not started.");
}
this.session.end();
};
/**
* @method handleBlizzardError
* @protected
*/
Client.prototype.handleBlizzardError = function (err) {
if (err.code === "ECONNRESET") {
this.emit("error", new Error("Server does not speak Yeti's protocol. Version mismatch?"));
return;
}
this.emit("error", err);
};
/**
* Create and submit a batch of tests to the Hub.
*
* @method createBatch
* @param {Object} config Batch information.
* @param {String[]} config.tests Tests. Either relative paths to `config.basedir` or URL pathnames.
* @param {String} [config.basedir] Root path for serving tests. Required if `useProxy` is true or not provided.
* @param {String} [config.query] Query string additions for test URLs.
* @param {Number} [config.timeout] Per-test timeout in seconds. Default is 45 seconds.
* If no activity occurs before the timeout, the next test is loaded.
* @param {Boolean} [config.useProxy] True if tests are filenames to proxy to the Hub.
* false if they are literal URL pathnames.
* If not provided, defaults to true.
* @param {Boolean} [config.instrument] True if JavaScript files should be instrumented with Istanbul.
* @return {ClientBatch} batch The new ClientBatch object.
*/
Client.prototype.createBatch = function (config) {
if (!this.session) {
throw new Error("Not connected. Call connect() before creating a batch.");
}
config = shallowCopy(config);
if (undefined === config.useProxy) {
// Use the proxy by default.
config.useProxy = true;
}
return new ClientBatch(this.session, config);
};
/**
* Get an array of available agents on the Hub.
*
* @method getAgents
* @param {Function} cb Callback with agent names.
*/
Client.prototype.getAgents = function (cb) {
this.session.emit("rpc.agents", cb);
};
/**
* @class exports
* @static
*/
exports.Client = Client;
/**
* Create a new Yeti Client instance.
*
* @method createClient
* @param {String} url Yeti Hub HTTP URL.
* @return {Client} client Yeti Client instance.
*/
exports.createClient = function (url) {
return new Client(url);
};