From da61fe12d0a647eb51b3a10a4bd244e8e9550034 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 28 Aug 2014 00:35:07 +0100 Subject: [PATCH] Add dynamic node api Closes #322 - nodes modules can be installed/removed dynamically at runtime - nodes can be enabled/disabled - onpaletteadd/onpaletteremove api added to node definitions - initial implementation of nr-cli --- .gitignore | 1 + nodes/core/core/58-debug.html | 189 +++++++++---------- package.json | 3 +- public/red/comms.js | 16 +- public/red/main.js | 88 +++++++-- public/red/nodes.js | 125 +++++++++++-- public/red/ui/palette.js | 37 ++-- public/red/ui/sidebar.js | 5 + red/bin/nr-cli.js | 179 ++++++++++++++++++ red/nodes/index.js | 35 +++- red/nodes/registry.js | 236 +++++++++++++++++------ red/red.js | 9 +- red/server.js | 283 +++++++++++++++++++++++----- red/settings.js | 70 +++++++ red/storage/index.js | 39 ++-- red/storage/localfilesystem.js | 19 +- test/_spec.js | 54 +++--- test/nodes/helper.js | 6 +- test/red/bin/nr-cli_spec.js | 0 test/red/nodes/credentials_spec.js | 12 +- test/red/nodes/flows_spec.js | 51 +++-- test/red/nodes/index_spec.js | 67 ++++++- test/red/nodes/registry_spec.js | 288 +++++++++++++++++++++++------ test/red/settings_spec.js | 109 +++++++++++ 24 files changed, 1540 insertions(+), 381 deletions(-) create mode 100755 red/bin/nr-cli.js create mode 100644 red/settings.js create mode 100644 test/red/bin/nr-cli_spec.js create mode 100644 test/red/settings_spec.js diff --git a/.gitignore b/.gitignore index 3373c4fb2..e9e3725d2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ flows.backup nodes/node-red-nodes/ .npm /coverage +.config.json diff --git a/nodes/core/core/58-debug.html b/nodes/core/core/58-debug.html index 87843e32f..ebdf03ac1 100644 --- a/nodes/core/core/58-debug.html +++ b/nodes/core/core/58-debug.html @@ -88,101 +88,104 @@ } }); } + }, + onpaletteadd: function() { + var content = document.createElement("div"); + content.id = "tab-debug"; + + var toolbar = document.createElement("div"); + toolbar.id = "debug-toolbar"; + content.appendChild(toolbar); + + toolbar.innerHTML = '
'; + + var messages = document.createElement("div"); + messages.id = "debug-content"; + content.appendChild(messages); + + RED.sidebar.addTab("debug",content); + + function getTimestamp() { + var d = new Date(); + return d.toLocaleString(); + } + + var sbc = document.getElementById("debug-content"); + + var messageCount = 0; + var that = this; + RED._debug = function(msg) { + that.handleDebugMessage("",{ + name:"debug", + msg:msg + }); + } + + this.handleDebugMessage = function(t,o) { + var msg = document.createElement("div"); + msg.onmouseover = function() { + msg.style.borderRightColor = "#999"; + var n = RED.nodes.node(o.id); + if (n) { + n.highlighted = true; + n.dirty = true; + } + RED.view.redraw(); + }; + msg.onmouseout = function() { + msg.style.borderRightColor = ""; + var n = RED.nodes.node(o.id); + if (n) { + n.highlighted = false; + n.dirty = true; + } + RED.view.redraw(); + }; + msg.onclick = function() { + var node = RED.nodes.node(o.id); + if (node) { + RED.view.showWorkspace(node.z); + } + + }; + var name = (o.name?o.name:o.id).toString().replace(/&/g,"&").replace(//g,">"); + var topic = (o.topic||"").toString().replace(/&/g,"&").replace(//g,">"); + var payload = (o.msg||"").toString().replace(/&/g,"&").replace(//g,">"); + msg.className = 'debug-message'+(o.level?(' debug-message-level-'+o.level):'') + msg.innerHTML = ''+getTimestamp()+''+ + '['+name+']'+ + (o.topic?''+topic+'':'')+ + ''+payload+''; + var atBottom = (sbc.scrollHeight-messages.offsetHeight-sbc.scrollTop) < 5; + messageCount++; + $(messages).append(msg); + + if (messageCount > 200) { + $("#debug-content .debug-message:first").remove(); + messageCount--; + } + if (atBottom) { + $(sbc).scrollTop(sbc.scrollHeight); + } + }; + RED.comms.subscribe("debug",this.handleDebugMessage); + + $("#debug-tab-clear").click(function() { + $(".debug-message").remove(); + messageCount = 0; + RED.nodes.eachNode(function(node) { + node.highlighted = false; + node.dirty = true; + }); + RED.view.redraw(); + }); + }, + onpaletteremove: function() { + RED.comms.unsubscribe("debug",this.handleDebugMessage); + RED.sidebar.removeTab("debug"); + delete RED._debug; } }); - - (function() { - var content = document.createElement("div"); - content.id = "tab-debug"; - - var toolbar = document.createElement("div"); - toolbar.id = "debug-toolbar"; - content.appendChild(toolbar); - - toolbar.innerHTML = '
'; - - var messages = document.createElement("div"); - messages.id = "debug-content"; - content.appendChild(messages); - - RED.sidebar.addTab("debug",content); - - function getTimestamp() { - var d = new Date(); - return d.toLocaleString(); - } - - var sbc = document.getElementById("debug-content"); - - var messageCount = 0; - - RED._debug = function(msg) { - handleDebugMessage("",{ - name:"debug", - msg:msg - }); - } - - var handleDebugMessage = function(t,o) { - var msg = document.createElement("div"); - msg.onmouseover = function() { - msg.style.borderRightColor = "#999"; - var n = RED.nodes.node(o.id); - if (n) { - n.highlighted = true; - n.dirty = true; - } - RED.view.redraw(); - }; - msg.onmouseout = function() { - msg.style.borderRightColor = ""; - var n = RED.nodes.node(o.id); - if (n) { - n.highlighted = false; - n.dirty = true; - } - RED.view.redraw(); - }; - msg.onclick = function() { - var node = RED.nodes.node(o.id); - if (node) { - RED.view.showWorkspace(node.z); - } - - }; - var name = (o.name?o.name:o.id).toString().replace(/&/g,"&").replace(//g,">"); - var topic = (o.topic||"").toString().replace(/&/g,"&").replace(//g,">"); - var payload = (o.msg||"").toString().replace(/&/g,"&").replace(//g,">"); - msg.className = 'debug-message'+(o.level?(' debug-message-level-'+o.level):'') - msg.innerHTML = ''+getTimestamp()+''+ - '['+name+']'+ - (o.topic?''+topic+'':'')+ - ''+payload+''; - var atBottom = (sbc.scrollHeight-messages.offsetHeight-sbc.scrollTop) < 5; - messageCount++; - $(messages).append(msg); - - if (messageCount > 200) { - $("#debug-content .debug-message:first").remove(); - messageCount--; - } - if (atBottom) { - $(sbc).scrollTop(sbc.scrollHeight); - } - }; - RED.comms.subscribe("debug",handleDebugMessage); - - $("#debug-tab-clear").click(function() { - $(".debug-message").remove(); - messageCount = 0; - RED.nodes.eachNode(function(node) { - node.highlighted = false; - node.dirty = true; - }); - RED.view.redraw(); - }); - - })(); \n

this should be filtered out

\n"); + nodeConfig.should.equal("\n\n\n\n

this should be filtered out

\n"); done(); }).catch(function(e) { done(e); }); }); + it('stores the node list', function(done) { + var settings = { + nodesDir:[resourcesDir + "TestNode1",resourcesDir + "TestNode2",resourcesDir + "TestNode3"], + available: function() { return true; }, + set: function(s,v) {}, + get: function(s) { return null;} + } + var settingsSave = sinon.spy(settings,"set"); + typeRegistry.init(settings); + typeRegistry.load("wontexist",true).then(function() { + var list = typeRegistry.getNodeList(); + list.should.be.Array.and.have.length(3); + + settingsSave.callCount.should.equal(1); + settingsSave.firstCall.args[0].should.be.equal("nodes"); + var savedList = settingsSave.firstCall.args[1]; + + savedList[list[0].id].name == list[0].name; + savedList[list[1].id].name == list[1].name; + savedList[list[2].id].name == list[2].name; + + savedList[list[0].id].should.not.have.property("err"); + savedList[list[1].id].should.not.have.property("err"); + savedList[list[2].id].should.not.have.property("err"); + + done(); + }).catch(function(e) { + done(e); + }).finally(function() { + settingsSave.restore(); + }); + + }); + it('allows nodes to be added by filename', function(done) { - typeRegistry.init({}); + var settings = { + available: function() { return true; }, + set: function(s,v) {}, + get: function(s) { return null;} + } + typeRegistry.init(settings); typeRegistry.load("wontexist",true).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.be.empty; - typeRegistry.addNode({file: resourcesDir + "TestNode1/TestNode1.js"}).then(function(node) { + typeRegistry.addNode(resourcesDir + "TestNode1/TestNode1.js").then(function(node) { list = typeRegistry.getNodeList(); list[0].should.have.property("id"); list[0].should.have.property("name","TestNode1.js"); @@ -311,11 +357,11 @@ describe('NodeRegistry', function() { }); it('fails to add non-existent filename', function(done) { - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load("wontexist",true).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.be.empty; - typeRegistry.addNode({file: resourcesDir + "DoesNotExist/DoesNotExist.js"}).then(function(node) { + typeRegistry.addNode(resourcesDir + "DoesNotExist/DoesNotExist.js").then(function(node) { done(new Error("ENOENT not thrown")); }).otherwise(function(e) { e.code.should.eql("ENOENT"); @@ -328,7 +374,7 @@ describe('NodeRegistry', function() { }); it('returns node info by type or id', function(done) { - typeRegistry.init({}); + typeRegistry.init(settings); typeRegistry.load(resourcesDir + "TestNode1",true).then(function() { var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(1); @@ -357,7 +403,7 @@ describe('NodeRegistry', function() { it('rejects adding duplicate nodes', function(done) { - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load(resourcesDir + "TestNode1",true).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(1); @@ -376,18 +422,23 @@ describe('NodeRegistry', function() { }); it('removes nodes from the registry', function(done) { - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load(resourcesDir + "TestNode1",true).then(function() { var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(1); list[0].should.have.property("id"); list[0].should.have.property("name","TestNode1.js"); list[0].should.have.property("types",["test-node-1"]); + list[0].should.have.property("enabled",true); + list[0].should.have.property("loaded",true); typeRegistry.getNodeConfigs().length.should.be.greaterThan(0); var info = typeRegistry.removeNode(list[0].id); - info.should.eql(list[0]); + + info.should.have.property("id",list[0].id); + info.should.have.property("enabled",false); + info.should.have.property("loaded",false); typeRegistry.getNodeList().should.be.an.Array.and.be.empty; typeRegistry.getNodeConfigs().length.should.equal(0); @@ -403,7 +454,7 @@ describe('NodeRegistry', function() { }); it('rejects removing unknown nodes from the registry', function(done) { - typeRegistry.init({}); + typeRegistry.init(settings); typeRegistry.load("wontexist",true).then(function() { var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.be.empty; @@ -451,7 +502,7 @@ describe('NodeRegistry', function() { }); })(); - typeRegistry.init({}); + typeRegistry.init(settings); typeRegistry.load("wontexist",false).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(2); @@ -464,7 +515,7 @@ describe('NodeRegistry', function() { list[1].should.have.property("id"); list[1].should.have.property("name","TestNodeModule:TestNodeMod2"); list[1].should.have.property("types",["test-node-mod-2"]); - list[1].should.have.property("enabled",false); + list[1].should.have.property("enabled",true); list[1].should.have.property("err"); @@ -516,13 +567,12 @@ describe('NodeRegistry', function() { return result; }); })(); - - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load("wontexist",true).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.be.empty; - typeRegistry.addNode({module: "TestNodeModule"}).then(function(node) { + typeRegistry.addModule("TestNodeModule").then(function(node) { list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(2); list[0].should.have.property("id"); @@ -534,7 +584,7 @@ describe('NodeRegistry', function() { list[1].should.have.property("id"); list[1].should.have.property("name","TestNodeModule:TestNodeMod2"); list[1].should.have.property("types",["test-node-mod-2"]); - list[1].should.have.property("enabled",false); + list[1].should.have.property("enabled",true); list[1].should.have.property("err"); node.should.eql(list); @@ -552,13 +602,62 @@ describe('NodeRegistry', function() { }); }); + + it('rejects adding duplicate node modules', function(done) { + var fs = require("fs"); + var path = require("path"); + + var pathJoin = (function() { + var _join = path.join; + return sinon.stub(path,"join",function() { + if (arguments.length == 3 && arguments[2] == "package.json") { + return _join(resourcesDir,"TestNodeModule" + path.sep + "node_modules" + path.sep,arguments[1],arguments[2]); + } + if (arguments.length == 2 && arguments[1] == "TestNodeModule") { + return _join(resourcesDir,"TestNodeModule" + path.sep + "node_modules" + path.sep,arguments[1]); + } + return _join.apply(this,arguments); + }); + })(); + + var readdirSync = (function() { + var originalReaddirSync = fs.readdirSync; + var callCount = 0; + return sinon.stub(fs,"readdirSync",function(dir) { + var result = []; + if (callCount == 1) { + result = originalReaddirSync(resourcesDir + "TestNodeModule" + path.sep + "node_modules"); + } + callCount++; + return result; + }); + })(); + + typeRegistry.init(settingsWithStorage); + typeRegistry.load('wontexist',false).then(function(){ + var list = typeRegistry.getNodeList(); + list.should.be.an.Array.and.have.lengthOf(2); + typeRegistry.addModule("TestNodeModule").then(function(node) { + done(new Error("addModule resolved")); + }).otherwise(function(err) { + done(); + }); + }).catch(function(e) { + done(e); + }).finally(function() { + readdirSync.restore(); + pathJoin.restore(); + }); + }); + + it('fails to add non-existent module name', function(done) { - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load("wontexist",true).then(function(){ var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.be.empty; - typeRegistry.addNode({module: "DoesNotExistModule"}).then(function(node) { + typeRegistry.addModule("DoesNotExistModule").then(function(node) { done(new Error("ENOENT not thrown")); }).otherwise(function(e) { e.code.should.eql("MODULE_NOT_FOUND"); @@ -570,9 +669,80 @@ describe('NodeRegistry', function() { }); }); + it('removes nodes from the registry by module', function(done) { + var fs = require("fs"); + var path = require("path"); + + var pathJoin = (function() { + var _join = path.join; + return sinon.stub(path,"join",function() { + if (arguments.length == 3 && arguments[2] == "package.json") { + return _join(resourcesDir,"TestNodeModule" + path.sep + "node_modules" + path.sep,arguments[1],arguments[2]); + } + if (arguments.length == 2 && arguments[1] == "TestNodeModule") { + return _join(resourcesDir,"TestNodeModule" + path.sep + "node_modules" + path.sep,arguments[1]); + } + return _join.apply(this,arguments); + }); + })(); + + var readdirSync = (function() { + var originalReaddirSync = fs.readdirSync; + var callCount = 0; + return sinon.stub(fs,"readdirSync",function(dir) { + var result = []; + if (callCount == 1) { + result = originalReaddirSync(resourcesDir + "TestNodeModule" + path.sep + "node_modules"); + } + callCount++; + return result; + }); + })(); + + typeRegistry.init(settingsWithStorage); + typeRegistry.load('wontexist',false).then(function(){ + var list = typeRegistry.getNodeList(); + list.should.be.an.Array.and.have.lengthOf(2); + var res = typeRegistry.removeModule("TestNodeModule"); + + res.should.be.an.Array.and.have.lengthOf(2); + res[0].should.have.a.property("id",list[0].id); + res[1].should.have.a.property("id",list[1].id); + + list = typeRegistry.getNodeList(); + list.should.be.an.Array.and.be.empty; + + done(); + }).catch(function(e) { + done(e); + }).finally(function() { + readdirSync.restore(); + pathJoin.restore(); + }); + + }); + + it('fails to remove non-existent module name', function(done) { + typeRegistry.init(settings); + typeRegistry.load("wontexist",true).then(function(){ + var list = typeRegistry.getNodeList(); + list.should.be.an.Array.and.be.empty; + + /*jshint immed: false */ + (function() { + typeRegistry.removeModule("DoesNotExistModule"); + }).should.throw(); + + done(); + + }).catch(function(e) { + done(e); + }); + }); + it('allows nodes to be enabled and disabled', function(done) { - typeRegistry.init({}); + typeRegistry.init(settingsWithStorage); typeRegistry.load(resourcesDir+path.sep+"TestNode1",true).then(function() { var list = typeRegistry.getNodeList(); list.should.be.an.Array.and.have.lengthOf(1); @@ -583,7 +753,9 @@ describe('NodeRegistry', function() { var nodeConfig = typeRegistry.getNodeConfigs(); nodeConfig.length.should.be.greaterThan(0); - typeRegistry.disableNode(list[0].id); + var info = typeRegistry.disableNode(list[0].id); + info.should.have.property("id",list[0].id); + info.should.have.property("enabled",false); var list2 = typeRegistry.getNodeList(); list2.should.be.an.Array.and.have.lengthOf(1); @@ -591,7 +763,9 @@ describe('NodeRegistry', function() { typeRegistry.getNodeConfigs().length.should.equal(0); - typeRegistry.enableNode(list[0].id); + var info2 = typeRegistry.enableNode(list[0].id); + info2.should.have.property("id",list[0].id); + info2.should.have.property("enabled",true); var list3 = typeRegistry.getNodeList(); list3.should.be.an.Array.and.have.lengthOf(1); @@ -606,27 +780,25 @@ describe('NodeRegistry', function() { }); }); - it('does not allow a node with error to be enabled', function(done) { - typeRegistry.init({}); - typeRegistry.load(resourcesDir+path.sep+"TestNode3",true).then(function() { + it('fails to enable/disable non-existent nodes', function(done) { + typeRegistry.init(settings); + typeRegistry.load("wontexist",true).then(function() { var list = typeRegistry.getNodeList(); - list.should.be.an.Array.and.have.lengthOf(1); - list[0].should.have.property("id"); - list[0].should.have.property("name","TestNode3.js"); - list[0].should.have.property("enabled",false); - list[0].should.have.property("err"); + list.should.be.an.Array.and.be.empty; /*jshint immed: false */ (function() { - typeRegistry.enable(list[0].id); + typeRegistry.disableNode("123"); }).should.throw(); - + + /*jshint immed: false */ + (function() { + typeRegistry.enableNode("123"); + }).should.throw(); + done(); }).catch(function(e) { done(e); }); }); - - - }); diff --git a/test/red/settings_spec.js b/test/red/settings_spec.js new file mode 100644 index 000000000..40553ab19 --- /dev/null +++ b/test/red/settings_spec.js @@ -0,0 +1,109 @@ +/** + * Copyright 2014 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ +var should = require("should"); +var when = require("when"); + +var settings = require("../../red/settings"); + + +describe("red/settings", function() { + it('wraps the user settings as read-only properties', function() { + var userSettings = { + a: 123, + b: "test", + c: [1,2,3] + } + settings.init(userSettings); + + settings.available().should.be.false; + + settings.a.should.equal(123); + settings.b.should.equal("test"); + settings.c.should.be.an.Array.with.lengthOf(3); + + settings.get("a").should.equal(123); + settings.get("b").should.equal("test"); + settings.get("c").should.be.an.Array.with.lengthOf(3); + + /*jshint immed: false */ + (function() { + settings.a = 456; + }).should.throw(); + + settings.c.push(5); + settings.c.should.be.an.Array.with.lengthOf(4); + + /*jshint immed: false */ + (function() { + settings.set("a",456); + }).should.throw(); + + /*jshint immed: false */ + (function() { + settings.set("a",456); + }).should.throw(); + + /*jshint immed: false */ + (function() { + settings.get("unknown"); + }).should.throw(); + + /*jshint immed: false */ + (function() { + settings.set("unknown",456); + }).should.throw(); + + }); + + it('loads global settings from storage', function(done) { + var userSettings = { + a: 123, + b: "test", + c: [1,2,3] + } + var savedSettings = null; + var storage = { + getSettings: function() { + return when.resolve({globalA:789}); + }, + saveSettings: function(settings) { + savedSettings = settings; + return when.resolve(); + } + } + settings.init(userSettings); + + settings.available().should.be.false; + + /*jshint immed: false */ + (function() { + settings.get("unknown"); + }).should.throw(); + + settings.load(storage).then(function() { + settings.available().should.be.true; + settings.get("globalA").should.equal(789); + settings.set("globalA","abc").then(function() { + savedSettings.globalA.should.equal("abc"); + done(); + }); + }).otherwise(function(err) { + done(err); + }); + + + }); +});