diff --git a/CHANGELOG.md b/CHANGELOG.md index b677aaa..2701e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ +#### 0.3.4 + + - Update dependencies + +#### 0.3.3 + + - Add plugin stub to runtime (#73) @joepavitt + - Use compatible versions rather than specific version of dependencies (#70) @Pezmc + +#### 0.3.2 + + - Fix async module loading (#65) @knolleary + - Update README.md (#61) @andreasmarkussen + +#### 0.3.1 + + - Add support for async node modules (#63) @knolleary + +#### 0.3.0 + + - Require node.js >=14 + - Add `setFlows` so that node being tested can modify flows (#54) @Steve-Mcl + #### 0.2.7 + - Wait for startFlows to resolve before returning from loadFlow call - required with Node-RED 1.3+ - README.md: Update example unit test to report assertion failures - examples: lower-case_spec.js: Allow proper assertion failure reporting (#45) diff --git a/README.md b/README.md index 755fd65..d68576d 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,44 @@ Loads a flow then starts the flow. This function has the following arguments: * testNode: (object|array of objects) Module object of a node to be tested returned by require function. This node will be registered, and can be used in testFlows. * testFlow: (array of objects) Flow data to test a node. If you want to use flow data exported from Node-RED editor, export the flow to the clipboard and paste the content into your test scripts. * testCredentials: (object) Optional node credentials. -* cb: (function) Function to call back when testFlows has been started. +* cb: (function) Function to call back when testFlows has been started (not required when called wih `await`) + +### setFlows(testFlow, type, testCredentials, cb) + +Updates the currently loaded flow. This function has the following arguments: + +* testFlow: (array of objects) Flow data to test a node. If you want to use flow data exported from Node-RED editor, export the flow to the clipboard and paste the content into your test scripts. +* type: (string) Flow data to test a node. If you want to use flow data exported from Node-RED editor, export the flow to the clipboard and paste the content into your test scripts. +* testCredentials: (object) Optional node credentials. +* cb: (function) Function to call back when testFlows has been loaded (not required when called wih `await`) + +#### Example + +```js + it('should modify the flow then lower case of payload', async function () { + const flow = [ + { id: "n2", type: "helper" } + ] + await helper.load(lowerNode, flow) + const newFlow = [...flow] + newFlow.push( { id: "n1", type: "lower-case", name: "lower-case", wires:[['n2']] },) + await helper.setFlows(newFlow, "nodes") //update flows + const n1 = helper.getNode('n1') + n1.should.have.a.property('name', 'lower-case') + await new Promise((resolve, reject) => { + const n2 = helper.getNode('n2') + n2.on('input', function (msg) { + try { + msg.should.have.property('payload', 'hello'); + resolve() + } catch (err) { + reject(err); + } + }); + n1.receive({ payload: 'HELLO' }); + }); + }); +``` ### unload() @@ -384,8 +421,8 @@ Return the URL of the helper server including the ephemeral port used when start Return a spy on the logs to look for events from the node under test. For example: ```javascript -var logEvents = helper.log().args.filter(function(evt { - return evt[0].type == "batch"; +var logEvents = helper.log().args.filter(function(event) { + return event[0].type == "batch"; }); ``` diff --git a/examples/lower-case_spec.js b/examples/lower-case_spec.js index 5e32a30..bc942cf 100644 --- a/examples/lower-case_spec.js +++ b/examples/lower-case_spec.js @@ -54,4 +54,28 @@ describe('lower-case Node', function () { n1.receive({ payload: "UpperCase" }); }); }); + it('should modify the flow then lower case of payload', async function () { + const flow = [ + { id: "n2", type: "helper" } + ] + await helper.load(lowerNode, flow) + + const newFlow = [...flow] + newFlow.push( { id: "n1", type: "lower-case", name: "lower-case", wires:[['n2']] },) + await helper.setFlows(newFlow) + const n1 = helper.getNode('n1') + n1.should.have.a.property('name', 'lower-case') + await new Promise((resolve, reject) => { + const n2 = helper.getNode('n2') + n2.on('input', function (msg) { + try { + msg.should.have.property('payload', 'hello'); + resolve() + } catch (err) { + reject(err); + } + }); + n1.receive({ payload: 'HELLO' }); + }); + }); }); diff --git a/index.js b/index.js index c956922..daec3de 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ 'use strict'; const path = require("path"); +const process = require("process") const sinon = require("sinon"); const should = require('should'); const fs = require('fs'); @@ -25,17 +26,35 @@ var bodyParser = require("body-parser"); const express = require("express"); const http = require('http'); const stoppable = require('stoppable'); -const readPkgUp = require('read-pkg-up'); const semver = require('semver'); const EventEmitter = require('events').EventEmitter; const PROXY_METHODS = ['log', 'status', 'warn', 'error', 'debug', 'trace', 'send']; + +// Find the nearest package.json +function findPackageJson(dir) { + dir = path.resolve(dir || process.cwd()) + const { root } = path.parse(dir) + if (dir === root) { + return null + } + const packagePath = path.join(dir, 'package.json') + if (fs.existsSync(packagePath)) { + return { + path: packagePath, + packageJson: JSON.parse(fs.readFileSync(packagePath, 'utf-8')) + } + } else { + return findPackageJson(path.resolve(path.join(dir, '..'))) + } +} + /** * Finds the NR runtime path by inspecting environment */ function findRuntimePath() { - const upPkg = readPkgUp.sync(); + const upPkg = findPackageJson() // case 1: we're in NR itself if (upPkg.packageJson.name === 'node-red') { if (checkSemver(upPkg.packageJson.version,"<0.20.0")) { @@ -173,7 +192,7 @@ class NodeTestHelper extends EventEmitter { return this._settings; } - load(testNode, testFlow, testCredentials, cb) { + async load(testNode, testFlow, testCredentials, cb) { const log = this._log; const logSpy = this._logSpy = this._sandbox.spy(log, 'log'); logSpy.FATAL = log.FATAL; @@ -199,17 +218,35 @@ class NodeTestHelper extends EventEmitter { }); - if (typeof testCredentials === 'function') { cb = testCredentials; testCredentials = {}; } - - var storage = { + const conf = {flows:testFlow,credentials:testCredentials|| {}} + const storage = { + conf: conf, getFlows: function () { - return Promise.resolve({flows:testFlow,credentials:testCredentials}); + return Promise.resolve(conf); + }, + saveFlows: function(conf) { + storage.conf = conf; + return Promise.resolve(); } }; + + // mock out the runtime plugins api + const plugins = { + registerPlugin () { + return; + }, + getPlugin () { + return; + }, + getPluginsByType () { + return []; + } + } + // this._settings.logging = {console:{level:'off'}}; this._settings.available = function() { return false; } @@ -224,13 +261,13 @@ class NodeTestHelper extends EventEmitter { util: this._RED.util, settings: this._settings, storage: storage, + plugins: plugins, log: this._log, nodeApp: express(), adminApp: this._httpAdmin, library: {register: function() {}}, get server() { return self._server } } - redNodes.init(mockRuntime); redNodes.registerType("helper", function (n) { redNodes.createNode(this, n); @@ -252,12 +289,16 @@ class NodeTestHelper extends EventEmitter { Object.defineProperty(red, prop, propDescriptor); }); } + const initPromises = [] let preloadedCoreModules = new Set(); testFlow.forEach(n => { if (this._nodeModules.hasOwnProperty(n.type)) { // Go find the 'real' core node module and load it... - this._nodeModules[n.type](red); + const result = this._nodeModules[n.type](red); + if (result?.then) { + initPromises.push(result) + } preloadedCoreModules.add(this._nodeModules[n.type]); } }) @@ -267,26 +308,33 @@ class NodeTestHelper extends EventEmitter { } testNode.forEach(fn => { if (!preloadedCoreModules.has(fn)) { - fn(red); + const result = fn(red); + if (result?.then) { + initPromises.push(result) + } } }); - - return redNodes.loadFlows() - .then(redNodes.startFlows).then(() => { - should.deepEqual(testFlow, redNodes.getFlows().flows); - if(cb) cb(); - }); + try { + await Promise.all(initPromises); + await redNodes.loadFlows(); + await redNodes.startFlows(); + should.deepEqual(testFlow, redNodes.getFlows().flows); + if (cb) cb(); + } catch (error) { + if (cb) cb(error); + else throw error; + } } unload() { // TODO: any other state to remove between tests? this._redNodes.clearRegistry(); - this._logSpy.restore(); + this._logSpy && this._logSpy.restore(); this._sandbox.restore(); // internal API this._context.clean({allNodes:[]}); - return this._redNodes.stopFlows(); + return this._redNodes.stopFlows() } /** @@ -302,41 +350,106 @@ class NodeTestHelper extends EventEmitter { return this._redNodes.stopFlows(); } + /** + * Update flows + * @param {object|object[]} testFlow Flow data to test a node + * @param {"full"|"flows"|"nodes"} type The type of deploy mode "full", "flows" or "nodes" (defaults to "full") + * @param {object} [testCredentials] Optional node credentials + * @param {function} [cb] Optional callback (not required when called with await) + * @returns {Promise} + */ + async setFlows(testFlow, type, testCredentials, cb) { + const helper = this; + if (typeof testCredentials === 'string' ) { + cb = testCredentials; + testCredentials = {}; + } + if(!type || typeof type != "string") { + type = "full" + } + async function waitStarted() { + return new Promise((resolve, reject) => { + let timeover = setTimeout(() => { + if (timeover) { + timeover = null + reject(Error("timeout waiting event")) + } + }, 300); + function hander() { + clearTimeout(timeover) + helper._events.off('flows:started', hander) + if (timeover) { + timeover = null + resolve() + } + } + helper._events.on('flows:started', hander); // call resolve when its done + }); + } + try { + await this._redNodes.setFlows(testFlow, testCredentials || {}, type); + await waitStarted(); + + if (cb) cb(); + } catch (error) { + if (cb) cb(error); + else throw error; + } + } + request() { return request(this._httpAdmin); } - startServer(done) { - this._app = express(); - const server = stoppable(http.createServer((req, res) => { - this._app(req, res); - }), 0); - - this._RED.init(server,{ - logging:{console:{level:'off'}} - }); - server.listen(this._listenPort, this._address); - server.on('listening', () => { - this._port = server.address().port; - // internal API - this._comms.start(); - done(); - }); - this._server = server; + async startServer(cb) { + try { + await new Promise((resolve, reject) => { + this._app = express(); + const server = stoppable( + http.createServer((req, res) => this._app(req, res)), + 0 + ); + + this._RED.init(server, { + logging: { console: { level: 'off' } }, + }); + + server.listen(this._listenPort, this._address); + + server.on('listening', () => { + this._port = server.address().port; + this._comms.start(); + this._server = server; + resolve(); + }); + + server.on('error', reject); + }); + + if (cb) cb(); + } catch (error) { + if (cb) cb(error); + else throw error; + } } - - //TODO consider saving TCP handshake/server reinit on start/stop/start sequences - stopServer(done) { - if (this._server) { - try { - // internal API - this._comms.stop(); - this._server.stop(done); - } catch (e) { - done(); + + async stopServer(cb) { + try { + if (this._server) { + await new Promise((resolve, reject) => { + this._comms.stop(); + + this._server.stop((error) => { + if (error) reject(error); + else resolve(); + }); + }); } - } else { - done(); + + if (cb) cb(); + } catch (error) { + if (cb) cb(error); + else throw error; } } diff --git a/package.json b/package.json index db9ebbf..81a71d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-red-node-test-helper", - "version": "0.2.7", + "version": "0.3.4", "description": "A test framework for Node-RED nodes", "main": "index.js", "scripts": { @@ -13,18 +13,17 @@ "url": "https://github.com/node-red/node-red-node-test-helper.git" }, "dependencies": { - "express": "4.17.1", - "body-parser": "1.19.0", - "read-pkg-up": "7.0.1", - "semver": "7.3.4", + "body-parser": "^1.20.3", + "express": "^4.21.0", + "semver": "^7.5.4", "should": "^13.2.3", - "should-sinon": "0.0.6", - "sinon": "9.2.4", - "stoppable": "1.1.0", - "supertest": "4.0.2" + "should-sinon": "^0.0.6", + "sinon": "^11.1.2", + "stoppable": "^1.1.0", + "supertest": "^7.0.0" }, "devDependencies": { - "mocha": "~7.1.2" + "mocha": "^9.2.2" }, "contributors": [ { @@ -46,6 +45,6 @@ "node-red" ], "engines": { - "node": ">=8" + "node": ">=14" } }