UNPKG

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
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"]); }); }); });