diff --git a/CHANGELOG.md b/CHANGELOG.md index ee591143f..3fe1454fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +#### 0.19.1: Maintenance Release + + - Pull in latest twitter node + - Handle windows paths for context storage + - Handle persisting objects with circular refs in context + - Ensure js editor can expand to fill available space + - Add example localfilesystem contextStorage to settings + - Fix template node handling of nested context tags + #### 0.19: Milestone Release Editor @@ -59,6 +68,7 @@ Runtime - Handle loading empty nodesDir - Add 'private' property to userDir generated package.json - Add RED.require to allow nodes to access other modules + - Ensure add/remove modules are run sequentially #### 0.18.7: Maintenance Release diff --git a/package.json b/package.json index 592f7f5e6..df948eff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-red", - "version": "0.18.7", + "version": "0.19.1", "description": "A visual tool for wiring the Internet of Things", "homepage": "http://nodered.org", "license": "Apache-2.0", @@ -39,7 +39,7 @@ "bcryptjs": "2.4.3", "body-parser": "1.18.3", "cheerio": "0.22.0", - "clone": "2.1.1", + "clone": "2.1.2", "cookie": "0.3.1", "cookie-parser": "1.4.3", "cors": "2.8.4", @@ -50,7 +50,7 @@ "fs-extra": "5.0.0", "fs.notify": "0.0.4", "hash-sum": "1.0.2", - "i18next": "^11.4.0", + "i18next": "11.6.0", "is-utf8": "0.2.1", "js-yaml": "3.12.0", "json-stringify-safe": "5.0.1", @@ -60,11 +60,11 @@ "mime": "1.4.1", "mqtt": "2.18.3", "multer": "1.3.1", - "mustache": "2.3.0", + "mustache": "2.3.1", "node-red-node-email": "0.1.*", "node-red-node-feedparser": "^0.1.12", "node-red-node-rbe": "0.2.*", - "node-red-node-twitter": "*", + "node-red-node-twitter": "^1.1.0", "nopt": "4.0.1", "oauth2orize": "1.11.0", "on-headers": "1.0.1", @@ -72,10 +72,10 @@ "passport-http-bearer": "1.0.1", "passport-oauth2-client-password": "0.1.2", "raw-body": "2.3.3", - "request": "2.87.0", + "request": "2.88.0", "semver": "5.5.0", "sentiment": "2.1.0", - "uglify-js": "3.4.5", + "uglify-js": "3.4.7", "when": "3.7.8", "ws": "1.1.5", "xml2js": "0.4.19" @@ -84,7 +84,7 @@ "bcrypt": "~2.0.0" }, "devDependencies": { - "chromedriver": "^2.40.0", + "chromedriver": "^2.41.0", "grunt": "~1.0.3", "grunt-chmod": "~1.1.1", "grunt-cli": "~1.2.0", @@ -94,7 +94,7 @@ "grunt-contrib-concat": "~1.0.1", "grunt-contrib-copy": "~1.0.0", "grunt-contrib-jshint": "~1.1.0", - "grunt-contrib-uglify": "~3.3.0", + "grunt-contrib-uglify": "~3.4.0", "grunt-contrib-watch": "~1.1.0", "grunt-jsdoc": "^2.2.1", "grunt-jsdoc-to-markdown": "^4.0.0", diff --git a/packages/node_modules/@node-red/editor/src/js/ui/editors/js.js b/packages/node_modules/@node-red/editor/src/js/ui/editors/js.js index 6e5cec287..cfb5a61c1 100644 --- a/packages/node_modules/@node-red/editor/src/js/ui/editors/js.js +++ b/packages/node_modules/@node-red/editor/src/js/ui/editors/js.js @@ -16,7 +16,7 @@ RED.editor.types._js = (function() { - var template = ''; + var template = ''; return { init: function() { @@ -58,7 +58,6 @@ RED.editor.types._js = (function() { for (var i=0;i').css({ @@ -141,23 +156,12 @@ $("#btn-navigate").click(function(evt) { evt.preventDefault(); - if (!isShowing) { - isShowing = true; - $("#btn-navigate").addClass("selected"); - resizeNavBorder(); - refreshNodes(); - $("#chart").on("scroll",onScroll); - navContainer.fadeIn(200); - } else { - isShowing = false; - navContainer.fadeOut(100); - $("#chart").off("scroll",onScroll); - $("#btn-navigate").removeClass("selected"); - } + toggle(); }) }, refresh: refreshNodes, - resize: resizeNavBorder + resize: resizeNavBorder, + toggle: toggle } diff --git a/packages/node_modules/@node-red/editor/src/sass/userSettings.scss b/packages/node_modules/@node-red/editor/src/sass/userSettings.scss index d8f7fc99b..61ccf8f2d 100644 --- a/packages/node_modules/@node-red/editor/src/sass/userSettings.scss +++ b/packages/node_modules/@node-red/editor/src/sass/userSettings.scss @@ -61,7 +61,13 @@ } #user-settings-tab-view { + position: absolute; + top:0; + right: 0; + left: 0; + bottom: 0; padding: 8px 20px 20px; + overflow-y: scroll; } .user-settings-row { padding: 5px 10px 2px; diff --git a/packages/node_modules/@node-red/nodes/core/core/80-template.js b/packages/node_modules/@node-red/nodes/core/core/80-template.js index f6e96a857..5700e2f55 100644 --- a/packages/node_modules/@node-red/nodes/core/core/80-template.js +++ b/packages/node_modules/@node-red/nodes/core/core/80-template.js @@ -19,6 +19,19 @@ module.exports = function(RED) { var mustache = require("mustache"); var yaml = require("js-yaml"); + function extractTokens(tokens,set) { + set = set || new Set(); + tokens.forEach(function(token) { + if (token[0] !== 'text') { + set.add(token[1]); + if (token.length > 4) { + extractTokens(token[4],set); + } + } + }); + return set; + } + function parseContext(key) { var match = /^(flow|global)(\[(\w+)\])?\.(.+)/.exec(key); if (match) { @@ -36,22 +49,16 @@ module.exports = function(RED) { * flow and global context */ - function NodeContext(msg, nodeContext, parent, escapeStrings, promises, results) { + function NodeContext(msg, nodeContext, parent, escapeStrings, cachedContextTokens) { this.msgContext = new mustache.Context(msg,parent); this.nodeContext = nodeContext; this.escapeStrings = escapeStrings; - this.promises = promises; - this.results = results; + this.cachedContextTokens = cachedContextTokens; } NodeContext.prototype = new mustache.Context(); NodeContext.prototype.lookup = function (name) { - var results = this.results; - if (results) { - var val = results.shift(); - return val; - } // try message first: try { var value = this.msgContext.lookup(name); @@ -64,7 +71,6 @@ module.exports = function(RED) { value = value.replace(/\f/g, "\\f"); value = value.replace(/[\b]/g, "\\b"); } - this.promises.push(Promise.resolve(value)); return value; } @@ -76,28 +82,10 @@ module.exports = function(RED) { var field = context.field; var target = this.nodeContext[type]; if (target) { - var promise = new Promise((resolve, reject) => { - var callback = (err, val) => { - if (err) { - reject(err); - } else { - resolve(val); - } - }; - target.get(field, store, callback); - }); - this.promises.push(promise); - return ''; - } - else { - this.promises.push(Promise.resolve('')); - return ''; + return this.cachedContextTokens[name]; } } - else { - this.promises.push(Promise.resolve('')); - return ''; - } + return ''; } catch(err) { throw err; @@ -105,7 +93,7 @@ module.exports = function(RED) { } NodeContext.prototype.push = function push (view) { - return new NodeContext(view, this.nodeContext, this.msgContext, undefined, this.promises, this.results); + return new NodeContext(view, this.nodeContext, this.msgContext, undefined, this.cachedContextTokens); }; function TemplateNode(n) { @@ -118,33 +106,36 @@ module.exports = function(RED) { this.outputFormat = n.output || "str"; var node = this; - node.on("input", function(msg) { - function output(value) { - /* istanbul ignore else */ - if (node.outputFormat === "json") { - value = JSON.parse(value); - } - /* istanbul ignore else */ - if (node.outputFormat === "yaml") { - value = yaml.load(value); - } - if (node.fieldType === 'msg') { - RED.util.setMessageProperty(msg, node.field, value); - node.send(msg); - } else if ((node.fieldType === 'flow') || - (node.fieldType === 'global')) { - var context = RED.util.parseContextStore(node.field); - var target = node.context()[node.fieldType]; - target.set(context.key, value, context.store, function (err) { - if (err) { - node.error(err, msg); - } else { - node.send(msg); - } - }); - } + function output(msg,value) { + /* istanbul ignore else */ + if (node.outputFormat === "json") { + value = JSON.parse(value); } + /* istanbul ignore else */ + if (node.outputFormat === "yaml") { + value = yaml.load(value); + } + + if (node.fieldType === 'msg') { + RED.util.setMessageProperty(msg, node.field, value); + node.send(msg); + } else if ((node.fieldType === 'flow') || + (node.fieldType === 'global')) { + var context = RED.util.parseContextStore(node.field); + var target = node.context()[node.fieldType]; + target.set(context.key, value, context.store, function (err) { + if (err) { + node.error(err, msg); + } else { + node.send(msg); + } + }); + } + } + + node.on("input", function(msg) { + try { /*** * Allow template contents to be defined externally @@ -160,19 +151,44 @@ module.exports = function(RED) { if (node.syntax === "mustache") { var is_json = (node.outputFormat === "json"); var promises = []; - mustache.render(template, new NodeContext(msg, node.context(), null, is_json, promises, null)); - Promise.all(promises).then(function (values) { - var value = mustache.render(template, new NodeContext(msg, node.context(), null, is_json, null, values)); - output(value); + var tokens = extractTokens(mustache.parse(template)); + var resolvedTokens = {}; + tokens.forEach(function(name) { + var context = parseContext(name); + if (context) { + var type = context.type; + var store = context.store; + var field = context.field; + var target = node.context()[type]; + if (target) { + var promise = new Promise((resolve, reject) => { + target.get(field, store, (err, val) => { + if (err) { + reject(err); + } else { + resolvedTokens[name] = val; + resolve(); + } + }); + }); + promises.push(promise); + return; + } + } + }); + + Promise.all(promises).then(function() { + var value = mustache.render(template, new NodeContext(msg, node.context(), null, is_json, resolvedTokens)); + output(msg, value); }).catch(function (err) { - node.error(err.message); + node.error(err.message,msg); }); } else { - output(template); + output(msg, template); } } catch(err) { - node.error(err.message); + node.error(err.message, msg); } }); } diff --git a/packages/node_modules/@node-red/runtime/nodes/context/index.js b/packages/node_modules/@node-red/runtime/nodes/context/index.js index 92a8e2854..fc6018956 100644 --- a/packages/node_modules/@node-red/runtime/nodes/context/index.js +++ b/packages/node_modules/@node-red/runtime/nodes/context/index.js @@ -112,7 +112,15 @@ function load() { try { // Create a new instance of the plugin by calling its module function stores[pluginName] = plugin(config); - log.info(log._("context.log-store-init", {name:pluginName, info:"module="+plugins[pluginName].module})); + var moduleInfo = plugins[pluginName].module; + if (typeof moduleInfo !== 'string') { + if (moduleInfo.hasOwnProperty("toString")) { + moduleInfo = moduleInfo.toString(); + } else { + moduleInfo = "custom"; + } + } + log.info(log._("context.log-store-init", {name:pluginName, info:"module="+moduleInfo})); } catch(err) { return reject(new Error(log._("context.error-loading-module",{module:pluginName,message:err.toString()}))); } diff --git a/packages/node_modules/@node-red/runtime/nodes/context/localfilesystem.js b/packages/node_modules/@node-red/runtime/nodes/context/localfilesystem.js index 33ca11430..ff9f11d8e 100644 --- a/packages/node_modules/@node-red/runtime/nodes/context/localfilesystem.js +++ b/packages/node_modules/@node-red/runtime/nodes/context/localfilesystem.js @@ -19,12 +19,16 @@ * * Configuration options: * { - * base: "contexts", // the base directory to use - * // default: "contexts" + * base: "context", // the base directory to use + * // default: "context" * dir: "/path/to/storage", // the directory to create the base directory in * // default: settings.userDir - * cache: true // whether to cache contents in memory + * cache: true, // whether to cache contents in memory * // default: true + * flushInterval: 30 // if cache is enabled, the minimum interval + * // between writes to storage, in seconds. This + * can be used to reduce wear on underlying storage. + * default: 30 seconds * } * * @@ -44,9 +48,12 @@ var fs = require('fs-extra'); var path = require("path"); var util = require("../../util"); +var log = require("../../log"); +var safeJSONStringify = require("json-stringify-safe"); var MemoryStore = require("./memory"); + function getStoragePath(storageBaseDir, scope) { if(scope.indexOf(":") === -1){ if(scope === "global"){ @@ -61,7 +68,7 @@ function getStoragePath(storageBaseDir, scope) { } function getBasePath(config) { - var base = config.base || "contexts"; + var base = config.base || "context"; var storageBaseDir; if (!config.dir) { if(config.settings && config.settings.userDir){ @@ -102,12 +109,38 @@ function loadFile(storagePath){ }); } +function listFiles(storagePath) { + var promises = []; + return fs.readdir(storagePath).then(function(files) { + files.forEach(function(file) { + promises.push(fs.readdir(path.join(storagePath,file)).then(function(subdirFiles) { + return subdirFiles.map(subfile => path.join(file,subfile)); + })) + }); + return Promise.all(promises); + }).then(dirs => dirs.reduce((acc, val) => acc.concat(val), [])); +} + +function stringify(value) { + var hasCircular; + var result = safeJSONStringify(value,null,4,function(k,v){hasCircular = true}) + return { json: result, circular: hasCircular }; +} + function LocalFileSystem(config){ this.config = config; this.storageBaseDir = getBasePath(this.config); if (config.hasOwnProperty('cache')?config.cache:true) { this.cache = MemoryStore({}); } + this.pendingWrites = {}; + this.knownCircularRefs = {}; + + if (config.hasOwnProperty('flushInterval')) { + this.flushInterval = Math.max(0,config.flushInterval) * 1000; + } else { + this.flushInterval = 30000; + } } LocalFileSystem.prototype.open = function(){ @@ -115,25 +148,17 @@ LocalFileSystem.prototype.open = function(){ if (this.cache) { var scopes = []; var promises = []; - var subdirs = []; - var subdirPromises = []; - return fs.readdir(self.storageBaseDir).then(function(dirs){ - dirs.forEach(function(fn) { - var p = getStoragePath(self.storageBaseDir ,fn)+".json"; - scopes.push(fn); - promises.push(loadFile(p)); - subdirs.push(path.join(self.storageBaseDir,fn)); - subdirPromises.push(fs.readdir(path.join(self.storageBaseDir,fn))); - }) - return Promise.all(subdirPromises); - }).then(function(dirs) { - dirs.forEach(function(files,i) { - files.forEach(function(fn) { - if (fn !== 'flow.json' && fn !== 'global.json') { - scopes.push(fn.substring(0,fn.length-5)+":"+scopes[i]); - promises.push(loadFile(path.join(subdirs[i],fn))) - } - }); + return listFiles(self.storageBaseDir).then(function(files) { + files.forEach(function(file) { + var parts = file.split(path.sep); + if (parts[0] === 'global') { + scopes.push("global"); + } else if (parts[1] === 'flow.json') { + scopes.push(parts[0]) + } else { + scopes.push(parts[1].substring(0,parts[1].length-5)+":"+parts[0]); + } + promises.push(loadFile(path.join(self.storageBaseDir,file))); }) return Promise.all(promises); }).then(function(res) { @@ -149,13 +174,40 @@ LocalFileSystem.prototype.open = function(){ }else{ return Promise.reject(err); } + }).then(function() { + self._flushPendingWrites = function() { + var scopes = Object.keys(self.pendingWrites); + self.pendingWrites = {}; + var promises = []; + var newContext = self.cache._export(); + scopes.forEach(function(scope) { + var storagePath = getStoragePath(self.storageBaseDir,scope); + var context = newContext[scope]; + var stringifiedContext = stringify(context); + if (stringifiedContext.circular && !self.knownCircularRefs[scope]) { + log.warn("Context "+scope+" contains a circular reference that cannot be persisted"); + self.knownCircularRefs[scope] = true; + } else { + delete self.knownCircularRefs[scope]; + } + log.debug("Flushing localfilesystem context scope "+scope); + promises.push(fs.outputFile(storagePath + ".json", stringifiedContext.json, "utf8")); + }); + delete self._pendingWriteTimeout; + return Promise.all(promises); + } }); } else { - return Promise.resolve(); + return fs.ensureDir(self.storageBaseDir); } } LocalFileSystem.prototype.close = function(){ + if (this.cache && this._flushPendingWrites) { + clearTimeout(this._pendingWriteTimeout); + delete this._pendingWriteTimeout; + return this._flushPendingWrites(); + } return Promise.resolve(); } @@ -188,13 +240,17 @@ LocalFileSystem.prototype.get = function(scope, key, callback) { }; LocalFileSystem.prototype.set = function(scope, key, value, callback) { + var self = this; var storagePath = getStoragePath(this.storageBaseDir ,scope); if (this.cache) { this.cache.set(scope,key,value,callback); - // With cache enabled, no need to re-read the file prior to writing. - var newContext = this.cache._export()[scope]; - fs.outputFile(storagePath + ".json", JSON.stringify(newContext, undefined, 4), "utf8").catch(function(err) { - }); + this.pendingWrites[scope] = true; + if (this._pendingWriteTimeout) { + // there's a pending write which will handle this + return; + } else { + this._pendingWriteTimeout = setTimeout(function() { self._flushPendingWrites.call(self)}, this.flushInterval); + } } else if (callback && typeof callback !== 'function') { throw new Error("Callback must be a function"); } else { @@ -214,7 +270,14 @@ LocalFileSystem.prototype.set = function(scope, key, value, callback) { } util.setObjectProperty(obj,key[i],v); } - return fs.outputFile(storagePath + ".json", JSON.stringify(obj, undefined, 4), "utf8"); + var stringifiedContext = stringify(obj); + if (stringifiedContext.circular && !self.knownCircularRefs[scope]) { + log.warn("Context "+scope+" contains a circular reference that cannot be persisted"); + self.knownCircularRefs[scope] = true; + } else { + delete self.knownCircularRefs[scope]; + } + return fs.outputFile(storagePath + ".json", stringifiedContext.json, "utf8"); }).then(function(){ if(typeof callback === "function"){ callback(null); @@ -254,36 +317,45 @@ LocalFileSystem.prototype.delete = function(scope){ cachePromise = Promise.resolve(); } var that = this; + delete this.pendingWrites[scope]; return cachePromise.then(function() { var storagePath = getStoragePath(that.storageBaseDir,scope); return fs.remove(storagePath + ".json"); }); } -LocalFileSystem.prototype.clean = function(activeNodes){ +LocalFileSystem.prototype.clean = function(_activeNodes) { + var activeNodes = {}; + _activeNodes.forEach(function(node) { activeNodes[node] = true }); var self = this; var cachePromise; if (this.cache) { - cachePromise = this.cache.clean(activeNodes); + cachePromise = this.cache.clean(_activeNodes); } else { cachePromise = Promise.resolve(); } - return cachePromise.then(function() { - return fs.readdir(self.storageBaseDir).then(function(dirs){ - return Promise.all(dirs.reduce(function(result, item){ - if(item !== "global" && activeNodes.indexOf(item) === -1){ - result.push(fs.remove(path.join(self.storageBaseDir,item))); - } - return result; - },[])); - }).catch(function(err){ - if(err.code == 'ENOENT') { - return Promise.resolve(); - }else{ - return Promise.reject(err); + this.knownCircularRefs = {}; + return cachePromise.then(() => listFiles(self.storageBaseDir)).then(function(files) { + var promises = []; + files.forEach(function(file) { + var parts = file.split(path.sep); + var removePromise; + if (parts[0] === 'global') { + // never clean global + return; + } else if (!activeNodes[parts[0]]) { + // Flow removed - remove the whole dir + removePromise = fs.remove(path.join(self.storageBaseDir,parts[0])); + } else if (parts[1] !== 'flow.json' && !activeNodes[parts[1].substring(0,parts[1].length-5)]) { + // Node removed - remove the context file + removePromise = fs.remove(path.join(self.storageBaseDir,file)); + } + if (removePromise) { + promises.push(removePromise); } }); - }); + return Promise.all(promises) + }) } module.exports = function(config){ diff --git a/settings.js b/settings.js index 1082332cc..6d6c6948b 100644 --- a/settings.js +++ b/settings.js @@ -213,6 +213,17 @@ module.exports = { // j5board:require("johnny-five").Board({repl:false}) }, + // Context Storage + // The following property can be used to enable context storage. The configuration + // provided here will enable file-based context that flushes to disk every 30 seconds. + // Refer to the documentation for further options: https://nodered.org/docs/api/context/ + // + //contextStorage: { + // default: { + // module:"localfilesystem" + // }, + //}, + // The following property can be used to order the categories in the editor // palette. If a node's category is not in the list, the category will get // added to the end of the palette. @@ -245,5 +256,5 @@ module.exports = { // To enable the Projects feature, set this value to true enabled: false } - } + }, } diff --git a/test/nodes/core/core/80-template_spec.js b/test/nodes/core/core/80-template_spec.js index 62db71179..acbfa093f 100644 --- a/test/nodes/core/core/80-template_spec.js +++ b/test/nodes/core/core/80-template_spec.js @@ -177,6 +177,53 @@ describe('template node', function() { }); }); + it('should handle nested context tags - property not set', function(done) { + // This comes from the Coursera Node-RED course and is a good example of + // multiple conditional tags + var template = `{{#flow.time}}time={{flow.time}}{{/flow.time}}{{^flow.time}}!time{{/flow.time}}{{#flow.random}}random={{flow.random}}randomtime={{flow.randomtime}}{{/flow.random}}{{^flow.random}}!random{{/flow.random}}`; + var flow = [{id:"n1",z:"t1", type:"template", field:"payload", template:template,wires:[["n2"]]},{id:"n2",z:"t1",type:"helper"}]; + helper.load(templateNode, flow, function() { + initContext(function() { + var n1 = helper.getNode("n1"); + var n2 = helper.getNode("n2"); + n2.on("input", function(msg) { + try { + msg.should.have.property('topic', 'bar'); + msg.should.have.property('payload', '!time!random'); + done(); + } catch(err) { + done(err); + } + }); + n1.receive({payload:"foo",topic: "bar"}); + }); + }); + }) + it('should handle nested context tags - property set', function(done) { + // This comes from the Coursera Node-RED course and is a good example of + // multiple conditional tags + var template = `{{#flow.time}}time={{flow.time}}{{/flow.time}}{{^flow.time}}!time{{/flow.time}}{{#flow.random}}random={{flow.random}}randomtime={{flow.randomtime}}{{/flow.random}}{{^flow.random}}!random{{/flow.random}}`; + var flow = [{id:"n1",z:"t1", type:"template", field:"payload", template:template,wires:[["n2"]]},{id:"n2",z:"t1",type:"helper"}]; + helper.load(templateNode, flow, function() { + initContext(function() { + var n1 = helper.getNode("n1"); + var n2 = helper.getNode("n2"); + n2.on("input", function(msg) { + try { + msg.should.have.property('topic', 'bar'); + msg.should.have.property('payload', 'time=123random=456randomtime=789'); + done(); + } catch(err) { + done(err); + } + }); + n1.context().flow.set(["time","random","randomtime"],["123","456","789"],function (err) { + n1.receive({payload:"foo",topic: "bar"}); + }); + }); + }); + }) + it('should modify payload from two persistable flow context', function(done) { var flow = [{id:"n1",z:"t1", type:"template", field:"payload", template:"payload={{flow[memory1].value}}/{{flow[memory2].value}}",wires:[["n2"]]},{id:"n2",z:"t1",type:"helper"}]; helper.load(templateNode, flow, function() { @@ -429,7 +476,7 @@ describe('template node', function() { n1.receive({payload:{A:"abc"}}); }); }); - + it('should raise error if passed bad template', function(done) { var flow = [{id:"n1", type:"template", field: "payload", template: "payload={{payload",wires:[["n2"]]},{id:"n2",type:"helper"}]; helper.load(templateNode, flow, function() { diff --git a/test/red/runtime/nodes/context/index_spec.js b/test/red/runtime/nodes/context/index_spec.js index 487e5aca2..9a2907ef3 100644 --- a/test/red/runtime/nodes/context/index_spec.js +++ b/test/red/runtime/nodes/context/index_spec.js @@ -17,6 +17,7 @@ var should = require("should"); var sinon = require('sinon'); var path = require("path"); +var fs = require('fs-extra'); var Context = require("../../../../../red/runtime/nodes/context/index"); describe('context', function() { @@ -273,6 +274,8 @@ describe('context', function() { sandbox.reset(); return Context.clean({allNodes:{}}).then(function(){ return Context.close(); + }).then(function(){ + return fs.remove(resourcesDir); }); }); @@ -286,11 +289,11 @@ describe('context', function() { }); it('should load memory module', function() { Context.init({contextStorage:{memory:{module:"memory"}}}); - Context.load(); + return Context.load(); }); it('should load localfilesystem module', function() { Context.init({contextStorage:{file:{module:"localfilesystem",config:{dir:resourcesDir}}}}); - Context.load(); + return Context.load(); }); it('should ignore reserved storage name `_`', function(done) { Context.init({contextStorage:{_:{module:testPlugin}}}); diff --git a/test/red/runtime/nodes/context/localfilesystem_spec.js b/test/red/runtime/nodes/context/localfilesystem_spec.js index 0d5e7062e..62028cdfe 100644 --- a/test/red/runtime/nodes/context/localfilesystem_spec.js +++ b/test/red/runtime/nodes/context/localfilesystem_spec.js @@ -21,32 +21,37 @@ var LocalFileSystem = require('../../../../../red/runtime/nodes/context/localfil var resourcesDir = path.resolve(path.join(__dirname,"..","resources","context")); +var defaultContextBase = "context"; + describe('localfilesystem',function() { - var context; before(function() { return fs.remove(resourcesDir); }); - beforeEach(function() { - context = LocalFileSystem({dir: resourcesDir, cache: false}); - return context.open(); - }); + describe('#get/set',function() { + var context; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + return context.open(); + }); - afterEach(function() { - return context.clean([]).then(function(){ - return context.close().then(function(){ + afterEach(function() { + return context.clean([]).then(function(){ + return context.close(); + }).then(function(){ return fs.remove(resourcesDir); }); }); - }); - describe('#get/set',function() { it('should store property',function(done) { context.get("nodeX","foo",function(err, value){ + if (err) { return done(err); } should.not.exist(value); context.set("nodeX","foo","test",function(err){ + if (err) { return done(err); } context.get("nodeX","foo",function(err, value){ + if (err) { return done(err); } value.should.be.equal("test"); done(); }); @@ -348,7 +353,7 @@ describe('localfilesystem',function() { }); it('should handle empty context file', function (done) { - fs.outputFile(path.join(resourcesDir,"contexts","nodeX","flow.json"),"",function(){ + fs.outputFile(path.join(resourcesDir,defaultContextBase,"nodeX","flow.json"),"",function(){ context.get("nodeX", "foo", function (err, value) { should.not.exist(value); context.set("nodeX", "foo", "test", function (err) { @@ -362,7 +367,7 @@ describe('localfilesystem',function() { }); it('should throw an error when reading corrupt context file', function (done) { - fs.outputFile(path.join(resourcesDir, "contexts", "nodeX", "flow.json"),"{abc",function(){ + fs.outputFile(path.join(resourcesDir, defaultContextBase, "nodeX", "flow.json"),"{abc",function(){ context.get("nodeX", "foo", function (err, value) { should.exist(err); done(); @@ -372,6 +377,20 @@ describe('localfilesystem',function() { }); describe('#keys',function() { + var context; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + return context.open(); + }); + + afterEach(function() { + return context.clean([]).then(function(){ + return context.close(); + }).then(function(){ + return fs.remove(resourcesDir); + }); + }); + it('should enumerate context keys', function(done) { context.keys("nodeX",function(err, value){ value.should.be.an.Array(); @@ -436,6 +455,20 @@ describe('localfilesystem',function() { }); describe('#delete',function() { + var context; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + return context.open(); + }); + + afterEach(function() { + return context.clean([]).then(function(){ + return context.close(); + }).then(function(){ + return fs.remove(resourcesDir); + }); + }); + it('should delete context',function(done) { context.get("nodeX","foo",function(err, value){ should.not.exist(value); @@ -466,64 +499,36 @@ describe('localfilesystem',function() { }); describe('#clean',function() { - it('should clean unnecessary context',function(done) { - context.get("nodeX","foo",function(err, value){ - should.not.exist(value); - context.get("nodeY","foo",function(err, value){ - should.not.exist(value); - context.set("nodeX","foo","testX",function(err){ - context.set("nodeY","foo","testY",function(err){ - context.get("nodeX","foo",function(err, value){ - value.should.be.equal("testX"); - context.get("nodeY","foo",function(err, value){ - value.should.be.equal("testY"); - context.clean([]).then(function(){ - context.get("nodeX","foo",function(err, value){ - should.not.exist(value); - context.get("nodeY","foo",function(err, value){ - should.not.exist(value); - done(); - }); - }); - }); - }); - }); - }); - }); + var context; + var contextGet; + var contextSet; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + contextGet = function(scope,key) { + return new Promise((res,rej) => { + context.get(scope,key, function(err,value) { + if (err) { + rej(err); + } else { + res(value); + } + }) }); - }); + } + contextSet = function(scope,key,value) { + return new Promise((res,rej) => { + context.set(scope,key,value, function(err) { + if (err) { + rej(err); + } else { + res(); + } + }) + }); + } + return context.open(); }); - it('should not clean active context',function(done) { - context.get("nodeX","foo",function(err, value){ - should.not.exist(value); - context.get("nodeY","foo",function(err, value){ - should.not.exist(value); - context.set("nodeX","foo","testX",function(err){ - context.set("nodeY","foo","testY",function(err){ - context.get("nodeX","foo",function(err, value){ - value.should.be.equal("testX"); - context.get("nodeY","foo",function(err, value){ - value.should.be.equal("testY"); - context.clean(["nodeX"]).then(function(){ - context.get("nodeX","foo",function(err, value){ - value.should.be.equal("testX"); - context.get("nodeY","foo",function(err, value){ - should.not.exist(value); - done(); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - - describe('#if cache is enabled',function() { afterEach(function() { return context.clean([]).then(function(){ return context.close().then(function(){ @@ -531,23 +536,101 @@ describe('localfilesystem',function() { }); }); }); + it('should clean unnecessary context',function(done) { + contextSet("global","foo","testGlobal").then(function() { + return contextSet("nodeX:flow1","foo","testX"); + }).then(function() { + return contextSet("nodeY:flow2","foo","testY"); + }).then(function() { + return contextGet("nodeX:flow1","foo"); + }).then(function(value) { + value.should.be.equal("testX"); + }).then(function() { + return contextGet("nodeY:flow2","foo"); + }).then(function(value) { + value.should.be.equal("testY"); + }).then(function() { + return context.clean([]) + }).then(function() { + return contextGet("nodeX:flow1","foo"); + }).then(function(value) { + should.not.exist(value); + }).then(function() { + return contextGet("nodeY:flow2","foo"); + }).then(function(value) { + should.not.exist(value); + }).then(function() { + return contextGet("global","foo"); + }).then(function(value) { + value.should.eql("testGlobal"); + }).then(done).catch(done); + }); + + it('should not clean active context',function(done) { + contextSet("global","foo","testGlobal").then(function() { + return contextSet("nodeX:flow1","foo","testX"); + }).then(function() { + return contextSet("nodeY:flow2","foo","testY"); + }).then(function() { + return contextGet("nodeX:flow1","foo"); + }).then(function(value) { + value.should.be.equal("testX"); + }).then(function() { + return contextGet("nodeY:flow2","foo"); + }).then(function(value) { + value.should.be.equal("testY"); + }).then(function() { + return context.clean(["flow1","nodeX"]) + }).then(function() { + return contextGet("nodeX:flow1","foo"); + }).then(function(value) { + value.should.be.equal("testX"); + }).then(function() { + return contextGet("nodeY:flow2","foo"); + }).then(function(value) { + should.not.exist(value); + }).then(function() { + return contextGet("global","foo"); + }).then(function(value) { + value.should.eql("testGlobal"); + }).then(done).catch(done); + }); + }); + + describe('#if cache is enabled',function() { + + var context; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + return context.open(); + }); + + afterEach(function() { + return context.clean([]).then(function(){ + return context.close(); + }).then(function(){ + return fs.remove(resourcesDir); + }); + }); + + it('should load contexts into the cache',function() { var globalData = {key:"global"}; var flowData = {key:"flow"}; var nodeData = {key:"node"}; return Promise.all([ - fs.outputFile(path.join(resourcesDir,"contexts","global","global.json"), JSON.stringify(globalData,null,4), "utf8"), - fs.outputFile(path.join(resourcesDir,"contexts","flow","flow.json"), JSON.stringify(flowData,null,4), "utf8"), - fs.outputFile(path.join(resourcesDir,"contexts","flow","node.json"), JSON.stringify(nodeData,null,4), "utf8") + fs.outputFile(path.join(resourcesDir,defaultContextBase,"global","global.json"), JSON.stringify(globalData,null,4), "utf8"), + fs.outputFile(path.join(resourcesDir,defaultContextBase,"flow","flow.json"), JSON.stringify(flowData,null,4), "utf8"), + fs.outputFile(path.join(resourcesDir,defaultContextBase,"flow","node.json"), JSON.stringify(nodeData,null,4), "utf8") ]).then(function(){ context = LocalFileSystem({dir: resourcesDir, cache: true}); return context.open(); }).then(function(){ return Promise.all([ - fs.remove(path.join(resourcesDir,"contexts","global","global.json")), - fs.remove(path.join(resourcesDir,"contexts","flow","flow.json")), - fs.remove(path.join(resourcesDir,"contexts","flow","node.json")) + fs.remove(path.join(resourcesDir,defaultContextBase,"global","global.json")), + fs.remove(path.join(resourcesDir,defaultContextBase,"flow","flow.json")), + fs.remove(path.join(resourcesDir,defaultContextBase,"flow","node.json")) ]); }).then(function(){ context.get("global","key").should.be.equal("global"); @@ -557,19 +640,31 @@ describe('localfilesystem',function() { }); it('should store property to the cache',function() { - context = LocalFileSystem({dir: resourcesDir, cache: true}); + context = LocalFileSystem({dir: resourcesDir, cache: true, flushInterval: 1}); return context.open().then(function(){ return new Promise(function(resolve, reject){ context.set("global","foo","bar",function(err){ if(err){ reject(err); } else { - resolve(); + fs.readJson(path.join(resourcesDir,defaultContextBase,"global","global.json")).then(function(data) { + // File should not exist as flush hasn't happened + reject("File global/global.json should not exist"); + }).catch(function(err) { + setTimeout(function() { + fs.readJson(path.join(resourcesDir,defaultContextBase,"global","global.json")).then(function(data) { + data.should.eql({foo:'bar'}); + resolve(); + }).catch(function(err) { + reject(err); + }); + },1100) + }) } }); }); }).then(function(){ - return fs.remove(path.join(resourcesDir,"contexts","global","global.json")); + return fs.remove(path.join(resourcesDir,defaultContextBase,"global","global.json")); }).then(function(){ context.get("global","foo").should.be.equal("bar"); }) @@ -577,11 +672,11 @@ describe('localfilesystem',function() { it('should enumerate context keys in the cache',function() { var globalData = {foo:"bar"}; - fs.outputFile(path.join(resourcesDir,"contexts","global","global.json"), JSON.stringify(globalData,null,4), "utf8").then(function(){ - context = LocalFileSystem({dir: resourcesDir, cache: true}); + fs.outputFile(path.join(resourcesDir,defaultContextBase,"global","global.json"), JSON.stringify(globalData,null,4), "utf8").then(function(){ + context = LocalFileSystem({dir: resourcesDir, cache: true, flushInterval: 2}); return context.open() }).then(function(){ - return fs.remove(path.join(resourcesDir,"contexts","global","global.json")); + return fs.remove(path.join(resourcesDir,defaultContextBase,"global","global.json")); }).then(function(){ var keys = context.keys("global"); keys.should.have.length(1); @@ -596,7 +691,7 @@ describe('localfilesystem',function() { }); }); }).then(function(){ - return fs.remove(path.join(resourcesDir,"contexts","global","global.json")); + return fs.remove(path.join(resourcesDir,defaultContextBase,"global","global.json")); }).then(function(){ var keys = context.keys("global"); keys.should.have.length(2); @@ -605,7 +700,7 @@ describe('localfilesystem',function() { }); it('should delete context in the cache',function() { - context = LocalFileSystem({dir: resourcesDir, cache: true}); + context = LocalFileSystem({dir: resourcesDir, cache: true, flushInterval: 2}); return context.open().then(function(){ return new Promise(function(resolve, reject){ context.set("global","foo","bar",function(err){ @@ -628,10 +723,10 @@ describe('localfilesystem',function() { var flowAData = {key:"flowA"}; var flowBData = {key:"flowB"}; return Promise.all([ - fs.outputFile(path.join(resourcesDir,"contexts","flowA","flow.json"), JSON.stringify(flowAData,null,4), "utf8"), - fs.outputFile(path.join(resourcesDir,"contexts","flowB","flow.json"), JSON.stringify(flowBData,null,4), "utf8") + fs.outputFile(path.join(resourcesDir,defaultContextBase,"flowA","flow.json"), JSON.stringify(flowAData,null,4), "utf8"), + fs.outputFile(path.join(resourcesDir,defaultContextBase,"flowB","flow.json"), JSON.stringify(flowBData,null,4), "utf8") ]).then(function(){ - context = LocalFileSystem({dir: resourcesDir, cache: true}); + context = LocalFileSystem({dir: resourcesDir, cache: true, flushInterval: 2}); return context.open(); }).then(function(){ context.get("flowA","key").should.be.equal("flowA"); @@ -645,6 +740,19 @@ describe('localfilesystem',function() { }); describe('Configuration', function () { + var context; + beforeEach(function() { + context = LocalFileSystem({dir: resourcesDir, cache: false}); + return context.open(); + }); + + afterEach(function() { + return context.clean([]).then(function(){ + return context.close(); + }).then(function(){ + return fs.remove(resourcesDir); + }); + }); it('should change a base directory', function (done) { var differentBaseContext = LocalFileSystem({ base: "contexts2", @@ -688,7 +796,7 @@ describe('localfilesystem',function() { it('should use NODE_RED_HOME', function (done) { var oldNRH = process.env.NODE_RED_HOME; process.env.NODE_RED_HOME = resourcesDir; - fs.mkdirSync(resourcesDir); + fs.ensureDirSync(resourcesDir); fs.writeFileSync(path.join(resourcesDir,".config.json"),""); var nrHomeContext = LocalFileSystem({ base: "contexts2",