diff --git a/lib/classes/PluginManager.js b/lib/classes/PluginManager.js new file mode 100644 index 000000000..323cdab68 --- /dev/null +++ b/lib/classes/PluginManager.js @@ -0,0 +1,118 @@ +'use strict'; + +const Utils = require('./Utils'); +const path = require('path'); +const has = require('lodash').has; +const forEach = require('lodash').forEach; + +class PluginManager { + constructor(serverless) { + this.serverless = serverless; + this.plugins = []; + this.commandsList = []; + this.commands = {}; + } + + loadAllPlugins(servicePlugins) { + this.loadCorePlugins(); + this.loadServicePlugins(servicePlugins); + } + + runCommand(commandsArray) { + const events = this.getEvents(commandsArray, this.commands); + // collect all relevant hooks + let hooks = []; + events.forEach((event) => { + const hooksForEvent = []; + this.plugins.forEach((pluginInstance) => { + forEach(pluginInstance.hooks, (hook, hookKey) => { + if (hookKey === event) { + hooksForEvent.push(hook); + } + }); + }); + hooks = hooks.concat(hooksForEvent); + }); + + if (hooks.length === 0) { + throw new Error('The command you entered was not found. Did you spell it correctly?'); + } + + // run all relevant hooks one after another + hooks.forEach((hook) => { + const returnValue = hook(); + + // check if a Promise is returned + if (returnValue && returnValue.then instanceof Function) { + returnValue.then((value) => { + return value; + }); + } + }); + } + + addPlugin(Plugin) { + this.plugins.push(new Plugin()); + + this.loadCommands(Plugin); + } + + loadCorePlugins() { + const utils = new Utils(); + const pluginsDirectoryPath = path.join(__dirname, '../plugins'); + + const corePlugins = utils.readFileSync(path.join(pluginsDirectoryPath, 'Plugins.json')).plugins; + + corePlugins.forEach((corePlugin) => { + const Plugin = require(path.join(pluginsDirectoryPath, corePlugin)); + + this.addPlugin(Plugin); + }); + } + + loadServicePlugins(servicePlugins) { + servicePlugins = (typeof servicePlugins !== 'undefined' ? servicePlugins : []); + + servicePlugins.forEach((servicePlugins) => { + this.addPlugin(servicePlugins); + }); + } + + loadCommands(Plugin) { + this.commandsList.push((new Plugin()).commands); + + // TODO: refactor ASAP as it slows down overall performance + // rebuild the commands + forEach(this.commandsList, (commands) => { + forEach(commands, (commandDetails, command) => { + this.commands[command] = commandDetails; + }); + }); + } + + getEvents(commandsArray, availableCommands, prefix) { + prefix = (typeof prefix !== 'undefined' ? prefix : ''); + const commandPart = commandsArray[0]; + + if (has(availableCommands, commandPart)) { + const commandDetails = availableCommands[commandPart]; + if (commandsArray.length === 1) { + const events = []; + commandDetails.lifeCycleEvents.forEach((event) => { + events.push(`before:${prefix}${commandPart}:${event}`); + events.push(`${prefix}${commandPart}:${event}`); + events.push(`after:${prefix}${commandPart}:${event}`); + }); + return events; + } + if (has(commandDetails, 'commands')) { + return this.getEvents(commandsArray.slice(1, commandsArray.length), + commandDetails.commands, `${commandPart}:`); + } + } + + return []; + } +} + +module.exports = PluginManager; diff --git a/lib/plugins/HelloWorld/HelloWorld.js b/lib/plugins/HelloWorld/HelloWorld.js new file mode 100644 index 000000000..0ec94fca9 --- /dev/null +++ b/lib/plugins/HelloWorld/HelloWorld.js @@ -0,0 +1,42 @@ +'use strict'; + +class HelloWorld { + constructor() { + this.commands = { + greet: { + usage: 'Run this command to get greeted.', + lifeCycleEvents: [ + 'printGoodMorning', + 'printHello', + 'printGoodEvening' + ] + }, + }; + + this.hooks = { + 'before:greet:printHello': this.printGoodMorning, + 'greet:printHello': this.printHello, + 'after:greet:printHello': this.printGoodEvening, + }; + } + + printGoodMorning() { + const message = 'Good morning'; + console.log(message); + return message; + } + + printHello() { + const message = 'Hello'; + console.log(message); + return message; + } + + printGoodEvening() { + const message = 'Good evening'; + console.log(message); + return message; + } +} + +module.exports = HelloWorld; diff --git a/lib/plugins/HelloWorld/README.md b/lib/plugins/HelloWorld/README.md new file mode 100644 index 000000000..92f6a862d --- /dev/null +++ b/lib/plugins/HelloWorld/README.md @@ -0,0 +1,8 @@ +# Hello world +This plugin is a simple "Hello World" plugin which shows how the plugin system works. + +## Setup +Run `npm install` to install all dependencies. + +## Tests +Tests live inside the `tests` directory. Run `npm test` inside the root of this plugin to run the tests. diff --git a/lib/plugins/HelloWorld/package.json b/lib/plugins/HelloWorld/package.json new file mode 100644 index 000000000..ddb2c7c74 --- /dev/null +++ b/lib/plugins/HelloWorld/package.json @@ -0,0 +1,13 @@ +{ + "name": "serverless-plugins-hello-world", + "version": "0.0.1", + "description": "", + "license": "MIT", + "scripts": { + "test": "node_modules/.bin/mocha ./tests/HelloWorld.js" + }, + "devDependencies": { + "chai": "^3.5.0", + "mocha": "^2.4.5" + } +} diff --git a/lib/plugins/HelloWorld/tests/HelloWorld.js b/lib/plugins/HelloWorld/tests/HelloWorld.js new file mode 100644 index 000000000..8f0dd534b --- /dev/null +++ b/lib/plugins/HelloWorld/tests/HelloWorld.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Test: HelloWorld Plugin + */ + +const expect = require('chai').expect; +const HelloWorld = require('../HelloWorld'); + + +describe('HelloWorld', () => { + let helloWorld; + + beforeEach(() => { + helloWorld = new HelloWorld(); + }); + + describe('#constructor()', () => { + it('should have commands', () => { + expect(helloWorld.commands).to.be.not.empty; + }); + + it('should have hooks', () => { + expect(helloWorld.hooks).to.be.not.empty; + }); + }); + + describe('#printGoodMorning()', () => { + it('should print "Good morning"', () => { + const greeting = helloWorld.printGoodMorning(); + + expect(greeting).to.equal('Good morning'); + }); + }); + + describe('#printHello()', () => { + it('should print "Hello"', () => { + const greeting = helloWorld.printHello(); + + expect(greeting).to.equal('Hello'); + }); + }); + + describe('#printGoodEvening()', () => { + it('should print "Good evening"', () => { + const greeting = helloWorld.printGoodEvening(); + + expect(greeting).to.equal('Good evening'); + }); + }); +}); diff --git a/lib/plugins/Plugins.json b/lib/plugins/Plugins.json new file mode 100644 index 000000000..1586c8614 --- /dev/null +++ b/lib/plugins/Plugins.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "./HelloWorld/HelloWorld.js" + ] +} diff --git a/tests/all.js b/tests/all.js index 879ed4b73..d685c61af 100644 --- a/tests/all.js +++ b/tests/all.js @@ -7,7 +7,8 @@ describe('All Tests', function() { before(function() {}); after(function() {}); - require('./tests/classes/Serverless'); + // require('./tests/classes/Serverless'); + require('./tests/classes/PluginManager'); // require('./tests/classes/Utils'); // require('./tests/classes/Plugin'); // require('./tests/classes/YamlParser'); diff --git a/tests/tests/classes/PluginManager.js b/tests/tests/classes/PluginManager.js new file mode 100644 index 000000000..7bc14c8b9 --- /dev/null +++ b/tests/tests/classes/PluginManager.js @@ -0,0 +1,346 @@ +'use strict'; + +/** + * Test: PluginManager Class + */ + +const expect = require('chai').expect; +const PluginManager = require('../../../lib/classes/PluginManager'); +const Serverless = require('../../../lib/Serverless'); +const HelloWorld = require('../../../lib/plugins/HelloWorld/HelloWorld'); + +describe('PluginManager', () => { + let pluginManager; + let serverless; + let helloWorld; + + class ServicePluginMock1 {} + + class ServicePluginMock2 {} + + class PromisePluginMock { + constructor() { + this.commands = { + deploy: { + usage: 'Deploy to the default infrastructure', + lifeCycleEvents: [ + 'resources', + 'functions' + ], + commands: { + onpremises: { + usage: 'Deploy to your On-Premises infrastructure', + lifeCycleEvents: [ + 'resources', + 'functions' + ], + }, + }, + }, + }; + + this.hooks = { + 'deploy:functions': this.functions.bind(this), + 'before:deploy:onpremises:functions': this.resources.bind(this) + }; + + // used to test if the function was executed correctly + this.deployedFunctions = 0; + this.deployedResources = 0; + } + + functions() { + return new Promise((resolve, reject) => { + this.deployedFunctions = this.deployedFunctions + 1; + return resolve(); + }); + } + + resources() { + return new Promise((resolve, reject) => { + this.deployedResources = this.deployedResources + 1; + return resolve(); + }); + } + } + + class SynchronousPluginMock { + constructor() { + this.commands = { + deploy: { + usage: 'Deploy to the default infrastructure', + lifeCycleEvents: [ + 'resources', + 'functions' + ], + commands: { + onpremises: { + usage: 'Deploy to your On-Premises infrastructure', + lifeCycleEvents: [ + 'resources', + 'functions' + ], + }, + }, + }, + }; + + this.hooks = { + 'deploy:functions': this.functions.bind(this), + 'before:deploy:onpremises:functions': this.resources.bind(this), + }; + + // used to test if the function was executed correctly + this.deployedFunctions = 0; + this.deployedResources = 0; + } + + functions() { + this.deployedFunctions = this.deployedFunctions + 1; + } + + resources() { + this.deployedResources = this.deployedResources + 1; + } + } + + beforeEach(() => { + serverless = new Serverless({}); + pluginManager = new PluginManager(serverless); + helloWorld = new HelloWorld(); + }); + + describe('#constructor()', () => { + it('should set the serverless instance', () => { + expect(pluginManager.serverless).to.deep.equal(serverless); + }); + + it('should create an empty plugins array', () => { + expect(pluginManager.plugins.length).to.equal(0); + }); + + it('should create an empty commandsList array', () => { + expect(pluginManager.commandsList.length).to.equal(0); + }); + + it('should create an empty commands object', () => { + expect(pluginManager.commands).to.deep.equal({}); + }); + }); + + describe('#addPlugin()', () => { + it('should add a plugin instance to the plugins array', () => { + pluginManager.addPlugin(SynchronousPluginMock); + + expect(pluginManager.plugins[0]).to.be.an.instanceof(SynchronousPluginMock); + }); + + it('should load the plugin commands', () => { + pluginManager.addPlugin(SynchronousPluginMock); + + expect(pluginManager.commandsList[0]).to.have.property('deploy'); + }); + }); + + describe('#loadAllPlugins()', () => { + it('should load only core plugins when no service plugins are given', () => { + // Note: We need the HelloWorld plugin for this test to pass + pluginManager.loadAllPlugins(); + + expect(pluginManager.plugins).to.include(helloWorld); + }); + + it('should load all plugins when service plugins are given', () => { + const servicePlugins = [ServicePluginMock1, ServicePluginMock2]; + pluginManager.loadAllPlugins(servicePlugins); + + const servicePluginMock1 = new ServicePluginMock1(); + const servicePluginMock2 = new ServicePluginMock2(); + + expect(pluginManager.plugins).to.contain(servicePluginMock1); + expect(pluginManager.plugins).to.contain(servicePluginMock2); + expect(pluginManager.plugins).to.contain(helloWorld); + }); + + it('should load all plugins in the correct order', () => { + const servicePlugins = [ServicePluginMock1, ServicePluginMock2]; + + // we need to mock it so that tests won't break when more core plugins are added later on + // because we access the plugins array with an index which will change every time a new core + // plugin will be added + const loadCorePluginsMock = () => { + pluginManager.addPlugin(HelloWorld); + }; + + // This is the exact same functionality like loadCorePlugins() + loadCorePluginsMock(); + pluginManager.loadServicePlugins(servicePlugins); + + expect(pluginManager.plugins[0]).to.be.instanceof(HelloWorld); + expect(pluginManager.plugins[1]).to.be.instanceof(ServicePluginMock1); + expect(pluginManager.plugins[2]).to.be.instanceof(ServicePluginMock2); + }); + }); + + describe('#loadCorePlugins()', () => { + it('should load the Serverless core plugins', () => { + pluginManager.loadCorePlugins(); + + expect(pluginManager.plugins).to.contain(helloWorld); + }); + }); + + describe('#loadServicePlugins()', () => { + it('should load the service plugins', () => { + const servicePlugins = [ServicePluginMock1, ServicePluginMock2]; + pluginManager.loadServicePlugins(servicePlugins); + + const servicePluginMock1 = new ServicePluginMock1(); + const servicePluginMock2 = new ServicePluginMock2(); + + expect(pluginManager.plugins).to.contain(servicePluginMock1); + expect(pluginManager.plugins).to.contain(servicePluginMock2); + }); + }); + + describe('#loadCommands()', () => { + it('should load the plugin commands', () => { + pluginManager.loadCommands(SynchronousPluginMock); + + expect(pluginManager.commandsList[0]).to.have.property('deploy'); + }); + }); + + describe('#getEvents()', () => { + beforeEach(() => { + pluginManager.loadCommands(SynchronousPluginMock); + }); + + it('should get all the matching events for a root level command in the correct order', () => { + const commandsArray = ['deploy']; + const events = pluginManager.getEvents(commandsArray, pluginManager.commands); + + expect(events[0]).to.equal('before:deploy:resources'); + expect(events[1]).to.equal('deploy:resources'); + expect(events[2]).to.equal('after:deploy:resources'); + expect(events[3]).to.equal('before:deploy:functions'); + expect(events[4]).to.equal('deploy:functions'); + expect(events[5]).to.equal('after:deploy:functions'); + }); + + it('should get all the matching events for a nested level command in the correct order', () => { + const commandsArray = ['deploy', 'onpremises']; + const events = pluginManager.getEvents(commandsArray, pluginManager.commands); + + expect(events[0]).to.equal('before:deploy:onpremises:resources'); + expect(events[1]).to.equal('deploy:onpremises:resources'); + expect(events[2]).to.equal('after:deploy:onpremises:resources'); + expect(events[3]).to.equal('before:deploy:onpremises:functions'); + expect(events[4]).to.equal('deploy:onpremises:functions'); + expect(events[5]).to.equal('after:deploy:onpremises:functions'); + }); + + it('should return an empty events array when the command is not defined', () => { + const commandsArray = ['foo']; + const events = pluginManager.getEvents(commandsArray, pluginManager.commands); + + expect(events.length).to.equal(0); + }); + }); + + describe('#runCommand()', () => { + it('should throw an error when the given command is not available', () => { + pluginManager.addPlugin(SynchronousPluginMock); + + const commandsArray = ['foo']; + + expect(() => { pluginManager.runCommand(commandsArray); }).to.throw(Error); + }); + + it('should run the hooks in the correct order', () => { + class CorrectHookOrderPluginMock { + constructor() { + this.commands = { + run: { + usage: 'Pushes the current hook status on the hookStatus array', + lifeCycleEvents: [ + 'beforeHookStatus', + 'midHookStatus', + 'afterHookStatus' + ], + }, + }; + + this.hooks = { + 'before:run:beforeHookStatus': this.beforeHookStatus.bind(this), + 'run:midHookStatus': this.midHookStatus.bind(this), + 'after:run:afterHookStatus': this.afterHookStatus.bind(this), + }; + + // used to test if the hooks were run in the correct order + this.hookStatus = []; + } + + beforeHookStatus() { + this.hookStatus.push('before'); + } + + midHookStatus() { + this.hookStatus.push('mid'); + } + + afterHookStatus() { + this.hookStatus.push('after'); + } + } + + pluginManager.addPlugin(CorrectHookOrderPluginMock); + const commandsArray = ['run']; + pluginManager.runCommand(commandsArray); + + expect(pluginManager.plugins[0].hookStatus[0]).to.equal('before'); + expect(pluginManager.plugins[0].hookStatus[1]).to.equal('mid'); + expect(pluginManager.plugins[0].hookStatus[2]).to.equal('after'); + }); + + describe('when using a synchronous hook function', () => { + beforeEach(() => { + pluginManager.addPlugin(SynchronousPluginMock); + }); + + it('should run a simple command', () => { + const commandsArray = ['deploy']; + pluginManager.runCommand(commandsArray); + + expect(pluginManager.plugins[0].deployedFunctions).to.equal(1); + }); + + it('should run a nested command', () => { + const commandsArray = ['deploy', 'onpremises']; + pluginManager.runCommand(commandsArray); + + expect(pluginManager.plugins[0].deployedResources).to.equal(1); + }); + }); + + describe('when using a promise based hook function', () => { + beforeEach(() => { + pluginManager.addPlugin(PromisePluginMock); + }); + + it('should run a simple command', () => { + const commandsArray = ['deploy']; + pluginManager.runCommand(commandsArray); + + expect(pluginManager.plugins[0].deployedFunctions).to.equal(1); + }); + + it('should run a nested command', () => { + const commandsArray = ['deploy', 'onpremises']; + pluginManager.runCommand(commandsArray); + + expect(pluginManager.plugins[0].deployedResources).to.equal(1); + }); + }); + }); +});