plugin-manager
Version:
Plugin manager for providing a simple 'hook' framework for an application, similarly to Wordpress or Drupal
665 lines (549 loc) • 23.3 kB
JavaScript
var libpath = process.env['PLUGIN_COV'] ? '../lib-cov' : '../lib';
var should = require("should")
, sinon = require("sinon")
, fs = require("fs")
, util = require("util")
, format = util.format
, plugin = require(libpath + "/plugin")
describe("Plugin", function() {
var plugInstance;
var error;
var warn;
beforeEach(function() {
plugInstance = new plugin.Plugin();
// Avoid error reporting by stubbing out the error function
error = sinon.stub(plugInstance.api.logger, "error");
warn = sinon.stub(plugInstance.api.logger, "warn");
});
describe("exports", function() {
it("should expose the Plugin class", function() {
plugin.should.have.property("Plugin");
plugin.Plugin.should.be.a("function");
new plugin.Plugin().should.be.an.instanceof(plugin.Plugin);
});
it("should expose a singleton instance", function() {
plugin.should.be.instanceof(plugin.Plugin);
});
});
describe("constructor", function() {
it("should set up default properties", function() {
plugInstance.modules.should.eql({});
// basePath is based on the app being run, so in this case it'll be our mocha binary, which
// we can't really predict in terms of path. Instead we just verify it's a string and not
// empty.
plugInstance.basePath.should.not.eql("");
plugInstance.basePath.should.be.a("string");
plugInstance.validHookNames.should.eql([]);
plugInstance.validateHookNames.should.be.false;
});
});
describe("#loadModule()", function() {
describe("(when the module has already been loaded)", function() {
beforeEach(function() {
plugInstance.pathLoaded["foo"] = true;
});
it("should return null", function() {
should.not.exist(plugInstance.loadModule("foo"));
});
it("should call our error logger with a specific error message", function() {
plugInstance.loadModule("foo");
error.callCount.should.eql(1)
var err = error.getCall(0).args[0]
err.should.eql(format(plugin.Plugin.MODULE_ALREADY_LOADED, "foo"));
});
});
describe("(when the module has never been loaded)", function() {
var fileRead;
describe("(and the module is missing its JSON file)", function() {
beforeEach(function() {
fileRead = sinon.stub(fs, "readFileSync");
fileRead.throws();
});
afterEach(function() {
fileRead.restore();
});
it("should return null", function() {
should.not.exist(plugInstance.loadModule("foo"));
});
it("should call our error logger with a specific error message", function() {
plugInstance.loadModule("foo");
error.callCount.should.eql(1)
var err = error.getCall(0).args[0]
err.should.eql(format(plugin.Plugin.UNABLE_TO_READ_MODULE, "foo"));
});
});
describe("(and the module has invalid JSON metadata)", function() {
var metaData;
var fakeJSON;
beforeEach(function() {
metaData = {};
fakeJSON = sinon.stub(JSON, "parse");
fakeJSON.returns(metaData);
fileRead = sinon.stub(fs, "readFileSync");
});
afterEach(function() {
fakeJSON.restore();
fileRead.restore();
});
it("Should return null", function() {
should.not.exist(plugInstance.loadModule("foo"));
});
it("should call our error logger with a specific error message", function() {
plugInstance.loadModule("foo");
error.callCount.should.eql(1)
var err = error.getCall(0).args[0]
err.should.eql(format(plugin.Plugin.MODULE_INVALID_SPEC, "foo"));
});
});
describe("(and the module has valid JSON metadata)", function() {
var fakeJSON;
var module;
var metaData;
// Set up some fake data for the plugin load
beforeEach(function() {
metaData = {name: "fakey", description: "The best fake module EVER", version: "0.0.1"};
fakeJSON = sinon.stub(JSON, "parse");
fakeJSON.returns(metaData);
fileRead = sinon.stub(fs, "readFileSync");
module = plugInstance.loadModule("foo");
});
afterEach(function() {
fakeJSON.restore();
fileRead.restore();
});
it("should set up the module's path", function() {
module.path.should.eql(plugInstance.basePath + "/foo");
});
it("should set up the module's metadata", function() {
module.should.have.property("meta", metaData);
});
it("should set the module as disabled", function() {
module.should.have.property("enabled", false);
});
it("should register the module", function() {
plugInstance.modules.should.have.property("fakey", module);
});
});
});
});
// It should be clear here that enableModule needs to be refactored - too much is happening
// here, which has caused this test to become ridiculous in terms of deep nesting of describes.
// This nesting is necessary to set up the appropriate data for each scenario (and sub-scenario
// and sub-sub-scenario, ...), but wouldn't need to happen if enableModule were calling functions
// instead of doing all this logic itself.
describe("#enableModule()", function() {
var loadModuleStub;
beforeEach(function() {
loadModuleStub = sinon.stub(plugInstance, "loadModule");
});
afterEach(function() {
loadModuleStub.restore();
});
describe("(when the module has not been loaded)", function() {
it("should not call loadModule", function() {
var module = "foo";
plugInstance.enableModule(module);
loadModuleStub.called.should.be.false;
});
it("should return false", function() {
plugInstance.enableModule("foo").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.enableModule("foo");
error.callCount.should.eql(1);
var err = error.getCall(0).args[0];
err.should.eql(format(plugin.Plugin.UNKNOWN_MODULE, "foo"));
});
});
describe("(when the module has been loaded)", function() {
describe("(and the module was already enabled)", function() {
beforeEach(function() {
plugInstance.modules["foo"] = {enabled: true};
});
it("should return false", function() {
plugInstance.enableModule("foo").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.enableModule("foo");
error.callCount.should.eql(1);
var err = error.getCall(0).args[0];
err.should.eql(format(plugin.Plugin.MODULE_ALREADY_ENABLED, "foo"));
});
});
describe("(and the handlers need to be loaded)", function() {
var module;
beforeEach(function() {
plugInstance.modules["foo"] = {enabled: false};
module = plugInstance.modules["foo"];
module.path = __dirname + "/fake";
});
it("should load the module and set its handlers", function() {
plugInstance.enableModule("foo");
should.exist(module.handlers);
var fake = require(module.path);
module.handlers.should.eql({"plugin.enable": fake.plugin.enable, "user.login": fake.user.login});
});
it("should call the plugin's onEnable handler if present", function() {
var m = require(module.path);
var spy = sinon.spy(m.plugin, "enable");
plugInstance.enableModule("foo");
spy.callCount.should.eql(1);
});
it("should tell the module how to access pluginManager functionality", function() {
module.path = __dirname + "/fake";
plugInstance.enableModule("foo");
require(module.path).pluginManager.should.eql(plugInstance);
});
// The only way to easily test this is by forcing a fake path and verifying it crashes as
// expected. (We really need to refactor the module)
describe("(and there is an error loading handlers)", function() {
beforeEach(function() {
module.path = "/foo";
});
it("should return false", function() {
plugInstance.enableModule("foo").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.enableModule("foo");
error.callCount.should.eql(1);
var err = error.getCall(0).args[0];
var match = new RegExp("^" + format(plugin.Plugin.UNABLE_TO_LOAD_HANDLERS, "foo", ".*"));
err.should.match(match)
});
});
});
describe("(and one or more event names are invalid)", function() {
beforeEach(function() {
var fakeHooks = [function() {}];
plugInstance.modules["fake"] = {enabled: false, path: __dirname + "/fake"};
plugInstance.validateHookNames = true
plugInstance.validHookNames = ["plugin.enable"];
});
it("should still return true", function() {
plugInstance.enableModule("fake").should.be.true;
});
// Another clear refactor situation - errors being string-only means we can't have a nice
// custom error object that tells us exactly what goes wrong... so we're doing string
// parsing instead of clear error object validations.
it("should call our warning logger with a specific message", function() {
plugInstance.enableModule("fake");
warn.callCount.should.eql(1);
var message = warn.getCall(0).args[0];
var match = new RegExp("^" + format(plugin.Plugin.INVALID_HANDLER_NAMES, "fake", "(.*)"));
var matches = match.exec(message);
matches[1].should.match(/user.login/);
});
});
});
});
describe("#disableModule", function() {
describe("(when the module is unknown)", function() {
it("should return false", function() {
plugInstance.disableModule("foo").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.disableModule("foo");
error.callCount.should.eql(1);
var err = error.getCall(0).args[0];
err.should.eql(format(plugin.Plugin.UNKNOWN_MODULE, "foo"));
});
});
describe("(when the module was already disabled)", function() {
beforeEach(function() {
plugInstance.modules["foo"] = {enabled: false};
});
it("should return false", function() {
plugInstance.disableModule("foo").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.disableModule("foo");
error.callCount.should.eql(1);
var err = error.getCall(0).args[0];
err.should.eql(format(plugin.Plugin.MODULE_ALREADY_DISABLED, "foo"));
});
});
describe("(when the module is loaded and enabled)", function() {
var fakeFuncOne;
var fakeFuncTwo;
var fakeFuncThree;
var module;
beforeEach(function() {
fakeFuncOne = function() {};
fakeFuncTwo = function() {};
fakeFuncThree = function() {};
plugInstance.modules["foo"] = {enabled: true, handlers: {hookOne: fakeFuncOne, hookTwo: fakeFuncTwo, hookThree: fakeFuncThree}};
module = plugInstance.modules["foo"];
});
it("should return true", function() {
plugInstance.disableModule("foo").should.be.true;
});
it("should set the module's enabled property to false", function() {
plugInstance.disableModule("foo");
module.enabled.should.be.false;
});
it("should call the module's onDisable handler if present", function() {
module.handlers["plugin.disable"] = function() {};
var spy = sinon.spy(module.handlers, "plugin.disable");
plugInstance.disableModule("foo");
spy.calledWith().should.be.true;
});
});
});
describe("#loadDirectory()", function() {
var readdirSyncStub;
var statSyncStub;
var loadModuleStub;
beforeEach(function() {
// First stub all calls we need to avoid - even if we don't test systems that call these, we
// still want to make sure they don't get called
readdirSyncStub = sinon.stub(fs, "readdirSync");
statSyncStub = sinon.stub(fs, "statSync");
loadModuleStub = sinon.stub(plugInstance, "loadModule");
});
afterEach(function() {
readdirSyncStub.restore();
statSyncStub.restore();
loadModuleStub.restore();
});
describe("(when the directory can't be read)", function() {
beforeEach(function() {
readdirSyncStub.throws();
});
it("should return false", function() {
plugInstance.loadDirectory("tmp").should.be.false;
});
it("should call our error logger with a specific error message", function() {
plugInstance.basePath = "/";
plugInstance.loadDirectory("tmp");
var err = error.getCall(0).args[0];
error.callCount.should.eql(1);
err.should.eql(format(plugin.Plugin.UNABLE_TO_READDIR, "/tmp"));
});
});
describe("(when the directory can be read)", function() {
var stats;
var files;
beforeEach(function() {
// Set up a basePath that makes testing easier
plugInstance.basePath = "/";
// Set up file list returned by readdir
files = ["file1", "dir1", "file2", "dir2"];
readdirSyncStub.returns(files);
// Set up stat objects for file vs. directory calls
stats = {};
stats.file = {};
stats.directory = {};
stats.file.isDirectory = function() { return false; }
stats.directory.isDirectory = function() { return true; }
// And finally, set up statSync's return values for each item in the files list
statSyncStub.withArgs("/tmp/file1").returns(stats.file);
statSyncStub.withArgs("/tmp/file2").returns(stats.file);
statSyncStub.withArgs("/tmp/dir1").returns(stats.directory);
statSyncStub.withArgs("/tmp/dir2").returns(stats.directory);
});
it("should call readdirSync on the top-level directory", function() {
plugInstance.loadDirectory("tmp");
readdirSyncStub.calledOnce.should.be.true;
readdirSyncStub.calledWith("/tmp").should.be.true;
});
it("should call statSync on each file returned", function() {
plugInstance.loadDirectory("tmp");
var len = files.length;
statSyncStub.callCount.should.eql(len);
for (var i = 0; i < len; i++) {
statSyncStub.calledWith("/tmp/" + files[i]).should.be.true;
}
});
it("should check isDirectory on each file", function() {
var isDirectorySpyFile = sinon.spy(stats.file, "isDirectory");
var isDirectorySpyDir = sinon.spy(stats.directory, "isDirectory");
plugInstance.loadDirectory("tmp");
isDirectorySpyFile.callCount.should.eql(2);
isDirectorySpyDir.callCount.should.eql(2);
isDirectorySpyFile.restore();
isDirectorySpyDir.restore();
});
it("should call loadModule for each item claiming to be a valid directory", function() {
plugInstance.loadDirectory("tmp");
loadModuleStub.callCount.should.eql(2);
// We don't use full path here because loadModule automatically prepends basePath for us
loadModuleStub.calledWith("tmp/dir1").should.be.true
loadModuleStub.calledWith("tmp/dir2").should.be.true
});
it("should return true", function() {
plugInstance.loadDirectory("tmp").should.be.true;
});
});
});
describe("#aliasFunctions", function() {
it("should create single-level aliasing of deeply-nested functions", function() {
var obj = {
a: {
i: {
a: function() {},
b: function() {}
},
ii: function() {}
},
b: {
i: function() {},
ii: function() {}
},
c: function() {}
};
var aliases = plugInstance.aliasFunctions(obj);
aliases["a.i.a"].should.eql(obj.a.i.a);
aliases["a.i.b"].should.eql(obj.a.i.b);
aliases["a.ii"].should.eql(obj.a.ii);
aliases["b.i"].should.eql(obj.b.i);
aliases["b.ii"].should.eql(obj.b.ii);
aliases["c"].should.eql(obj.c);
});
it("should join namespace objects on a period for alias keys", function() {
var obj = {a: function() {}};
var aliases = plugInstance.aliasFunctions(obj, {}, ["foo", "bar"]);
aliases["foo.bar.a"].should.eql(obj.a);
});
it("should use an existing alias hash if passed in", function() {
var obj = {a: function() {}};
var func = function() {};
var aliases = plugInstance.aliasFunctions(obj, {"a.b.c": func});
aliases["a"].should.eql(obj.a);
aliases["a.b.c"].should.eql(func);
});
});
describe("#invoke", function() {
var fakeFuncOne;
var module;
var moduleName;
var hookName;
beforeEach(function() {
fakeFuncOne = function() {};
moduleName = "foo";
hookName = "thing.event";
plugInstance.modules[moduleName] = {enabled: true, handlers: {}};
module = plugInstance.modules[moduleName];
module.handlers[hookName] = fakeFuncOne;
});
it("should return false if the module doesn't exist", function() {
plugInstance.invoke("fake module", hookName).should.be.false;
});
it("should return false if the hook isn't handled", function() {
plugInstance.invoke(moduleName, "fake hook").should.be.false;
});
it("should return false if the module isn't enabled", function() {
module.enabled = false;
plugInstance.invoke(moduleName, hookName).should.be.false;
});
it("should return true if the module exists and handles the given hook", function() {
plugInstance.invoke(moduleName, hookName).should.be.true;
});
it("should call the handler with all args sent after the module and hookname", function() {
// Make sure all the different arg-handling cases are working properly
var spy = sinon.spy(module.handlers, hookName)
plugInstance.invoke(moduleName, hookName);
plugInstance.invoke(moduleName, hookName, "1");
plugInstance.invoke(moduleName, hookName, "2.a", "2.b");
plugInstance.invoke(moduleName, hookName, "3.a", "3.b", "3.c");
plugInstance.invoke(moduleName, hookName, "4.a", "4.b", "4.c", "4.d");
plugInstance.invoke(moduleName, hookName, "5.a", "5.b", "5.c", "5.d", "5.e");
// Six calls: 0 args, 1 arg, 2 args, 3 args, 4 args, 5 args
spy.callCount.should.eql(6);
var args;
args = spy.getCall(0).args;
args.should.eql([]);
args = spy.getCall(1).args;
args.should.eql(["1"]);
args = spy.getCall(2).args;
args.should.eql(["2.a", "2.b"]);
args = spy.getCall(3).args;
args.should.eql(["3.a", "3.b", "3.c"]);
args = spy.getCall(4).args;
args.should.eql(["4.a", "4.b", "4.c", "4.d"]);
args = spy.getCall(5).args;
args.should.eql(["5.a", "5.b", "5.c", "5.d", "5.e"]);
});
});
describe("#invokeAll", function() {
var hookName1;
var hookName2;
var hookName3;
var module1;
var module2;
var moduleName1;
var moduleName2;
var spies;
beforeEach(function() {
// Alias names - this helps tests avoid typo-based false positives and false negatives
hookName1 = "cat.hat";
hookName2 = "thing.one";
hookName3 = "thing.two";
moduleName1 = "m1";
moduleName2 = "m2";
// Set up empty modules
plugInstance.modules[moduleName1] = {enabled: true, handlers: {}};
plugInstance.modules[moduleName2] = {enabled: true, handlers: {}};
module1 = plugInstance.modules[moduleName1];
module2 = plugInstance.modules[moduleName2];
// Populate handlers - module1 handles "cat.hat" and "thing.one" while module2 handles
// "cat.hat" and "thing.two"
module1.handlers[hookName1] = function() {};
module1.handlers[hookName2] = function() {};
module2.handlers[hookName1] = function() {};
module2.handlers[hookName3] = function() {};
// Set up spies since we'll need these in most tests I can envision
spies = {
m1h1: sinon.spy(module1.handlers, hookName1),
m1h2: sinon.spy(module1.handlers, hookName2),
m2h1: sinon.spy(module2.handlers, hookName1),
m2h3: sinon.spy(module2.handlers, hookName3)
};
});
it("should call handlers for enabled modules", function() {
plugInstance.invokeAll(hookName1);
spies.m1h1.callCount.should.eql(1);
spies.m2h1.callCount.should.eql(1);
});
it("should not call handlers for the modules' other methods", function() {
plugInstance.invokeAll(hookName1);
spies.m1h2.callCount.should.eql(0);
spies.m2h3.callCount.should.eql(0);
});
it("should not call handlers for disabled modules", function() {
module1.enabled = false
plugInstance.invokeAll(hookName1);
spies.m1h1.callCount.should.eql(0);
spies.m2h1.callCount.should.eql(1);
});
it("should call all handlers with expected arguments", function() {
plugInstance.invokeAll(hookName1);
plugInstance.invokeAll(hookName2, "1");
plugInstance.invokeAll(hookName3, "2.a", "2.b");
plugInstance.invokeAll(hookName1, "3.a", "3.b", "3.c");
plugInstance.invokeAll(hookName2, "4.a", "4.b", "4.c", "4.d");
plugInstance.invokeAll(hookName3, "5.a", "5.b", "5.c", "5.d", "5.e");
// First verify call counts are correct - each hook was invoked twice, and each module handles
// two hooks, so this should add up to eight total calls
spies.m1h1.callCount.should.eql(2);
spies.m1h2.callCount.should.eql(2);
spies.m2h1.callCount.should.eql(2);
spies.m2h3.callCount.should.eql(2);
// Now the really fun part - verify args were sent properly
var expected;
// Hook 1, call 1: both modules should have a call with no args
expected = [];
spies.m1h1.getCall(0).args.should.eql(expected);
spies.m2h1.getCall(0).args.should.eql(expected);
// Hook 1, call 2: both modules should have a call with 3 args
expected = ["3.a", "3.b", "3.c"];
spies.m1h1.getCall(1).args.should.eql(expected);
spies.m2h1.getCall(1).args.should.eql(expected);
// Hook 2: module one gets a one-arg call and a four-arg call
spies.m1h2.getCall(0).args.should.eql(["1"]);
spies.m1h2.getCall(1).args.should.eql(["4.a", "4.b", "4.c", "4.d"]);
// Hook 3: module two gets a two-arg call and a five-arg call
spies.m2h3.getCall(0).args.should.eql(["2.a", "2.b"]);
spies.m2h3.getCall(1).args.should.eql(["5.a", "5.b", "5.c", "5.d", "5.e"]);
});
});
});