diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..ef79ffe --- /dev/null +++ b/bin/README.md @@ -0,0 +1,27 @@ +Simple command line tool for runnning test cases. + +1. listing list of test cases + +``` +% node flow-test.js [--list|-l] [[--target|-t] URL] +``` + +show list of test cases on Node-RED instance specified by *URL*. + +Example: +``` +% node bin/flow-test.js --list +Sample - Global + 0: test click + 1: test send + 2: test wait + 3: test function +``` + +2. running a test case + +``` +% node flow-test.js [--run|-r] no [[--target|-t] URL] +``` + +run the test case specified by *no* on Node-RED instance specified by *URL*. diff --git a/bin/flow-test.js b/bin/flow-test.js new file mode 100644 index 0000000..a03ca40 --- /dev/null +++ b/bin/flow-test.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +'use strict' + +const nopt = require("nopt"); +const got = require("got"); + +function parseArgs(args) { + const knownOpts = { + target: String, + list: Boolean, + run: String + }; + const shortHands = { + 't': ["--target"], + 'l': ["--list"], + 'r': ["--run"], + 'j': ["--json"], + }; + return nopt(knownOpts, shortHands, args, 2); +} + + +async function getList(target) { + const url = `${target}/flow-tester/testCase`; + try { + const res = await got(url); + const body = JSON.parse(res.body); + return body; + } + catch (err) { + console.log(err); + } +} + +async function doList(target, jsonOutput) { + const list = await getList(target); + if (jsonOutput) { + console.log(JSON.stringify(list, undefined, "\t")); + return; + } + let id = 0; + list.forEach((tcase) => { + console.log(`${tcase.name}`); + const tests = tcase.tests; + tests.forEach((test) => { + console.log(` ${id}: ${test.name}`); + id++; + }); + }); +} + +async function doRun(target, no) { + try { + const cases = await getList(target); + let id = 0; + for (let tcase of cases) { + const tests = tcase.tests; + for (let test of tests) { + if (id === no) { + const url = `${target}/flow-tester/runTestCase/${tcase.id}/${test.id}`; + const res = await got(url); + const body = JSON.parse(res.body); + return body; + } + id++; + } + } + } + catch (err) { + console.log(err); + return undefined; + } +} + +async function main(args) { + const opts = parseArgs(args); + let target = "http://localhost:1880"; + if (opts.target) { + target = opts.target; + } + if (opts.list) { + await doList(target, opts.json); + } + if (opts.run) { + const res = await doRun(target, Number(opts.run)); + console.log(JSON.stringify(res, null, "\t")); + } +} + + +(async () => { + main(process.argv); +})(); diff --git a/examples/node-red-contrib-flow-tester-addon/.gitignore b/examples/node-red-contrib-flow-tester-addon/.gitignore new file mode 100644 index 0000000..75b9df9 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +package-lock.json +.nyc_output +resources diff --git a/examples/node-red-contrib-flow-tester-addon/LICENSE b/examples/node-red-contrib-flow-tester-addon/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/examples/node-red-contrib-flow-tester-addon/README.md b/examples/node-red-contrib-flow-tester-addon/README.md new file mode 100644 index 0000000..ca2e0fd --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/README.md @@ -0,0 +1,55 @@ +# node-red-flow-tester-addon + +An Sample Addon to Flow Testing plugin for Node-RED + +**This is under development and not ready for general use. If you want to contribute, come talk to us in the `#core-dev` channel on https://nodered.org/slack + +## Developing + +To use the development version of Flow Tester Addon you can clone its source code repository +and build it yourself. + +1. Get the source code + +``` +git clone https://github.com/node-red/node-red-flow-tester-addon.git +``` + +2. Install the dependencies + +``` +cd node-red-flow-tester-addon +npm install +``` + +3. Build the plugin + +``` +npm run build +``` + +4. Install into Node-RED + +``` +cd ~/.node-red +npm install +``` + +5. Restart Node-RED to load the plugin. + +### Source code structure + + - `scripts` - build scripts used by `npm run build` + - `src` - source code of the plugins + - `test` - tests material + +After `npm run build` is run, the following directories will be created: + + - `dist` - contains the built plugin files + - `resources` contains the built plugin resource files (eg css) + +## License + +Copyright Node-RED Project Contributors. + +Licensed under the [Apache License, Version 2.0](LICENSE). diff --git a/examples/node-red-contrib-flow-tester-addon/package.json b/examples/node-red-contrib-flow-tester-addon/package.json new file mode 100644 index 0000000..f29a632 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-red-contrib-flow-tester-addon", + "version": "0.0.1", + "description": "A test plugin for flow tester addon", + "scripts": { + "build": "node scripts/build.js", + "dev": "nodemon --exec 'npm run build' -i dist -i resources -e 'css js html'", + "test": "nyc mocha" + }, + "keywords": [ + "node-red", + "testing" + ], + "files": [ + "dist", + "resources" + ], + "license": "Apache-2", + "node-red": { + "version": ">=2.0.0", + "plugins": { + "flow-tester-addon": "dist/flow-tester-addon.js" + } + }, + "contributors": [ + { + "name": "Hiroyasu Nishiyama", + "email": "hiroyasu.nishiyama.uq@hitachi.com" + } + ], + "devDependencies": { + "fs-extra": "^10.0.0", + "html-minifier": "^4.0.0", + "nodemon": "^2.0.7", + "nyc": "^15.1.0", + "mocha": "^8.4.0" + } +} diff --git a/examples/node-red-contrib-flow-tester-addon/scripts/build.js b/examples/node-red-contrib-flow-tester-addon/scripts/build.js new file mode 100644 index 0000000..169b5c5 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/scripts/build.js @@ -0,0 +1,51 @@ +const minify = require("html-minifier").minify; +const fs = require("fs-extra"); +const path = require("path"); + + +const projectRoot = path.join(__dirname,"..") +const resources = path.join(projectRoot,"resources"); +const dist = path.join(projectRoot,"dist"); +const src = path.join(projectRoot,"src"); + +const assets = {} +assets[dist] = [ + "locales", + "flow-tester-addon.html", + "flow-tester-addon.js" +] +assets[resources] = [ + "style.css" +] + +async function copyStaticAssets(dist,assets) { + await fs.mkdir(dist,{recursive: true}); + for (let i=0; i"+rawCSS+"", {minifyCSS: true}); + const finalCSS = minifiedCSS.substring(7,minifiedCSS.length-8) + await fs.writeFile(path.join(dist,assets[i]), finalCSS) + } else { + await fs.mkdir(path.join(dist,assets[i]), {recursive: true}); + await fs.copy(path.join(src,assets[i]),path.join(dist,assets[i])) + } + } +} + + + +(async function() { + const destinations = Object.keys(assets); + for (let i=0, l=destinations.length; i { + console.error(err); + process.exit(1); +}); diff --git a/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.html b/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.html new file mode 100644 index 0000000..a2afe29 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.html @@ -0,0 +1,142 @@ +// Example addon for flow tester action + + + diff --git a/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.js b/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.js new file mode 100644 index 0000000..9ffc684 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/src/flow-tester-addon.js @@ -0,0 +1,62 @@ +module.exports = (RED) => { + "use strict"; + + console.log("; Initialize flow tester addon"); + RED.plugins.registerPlugin('node-red-flow-tester-addon', { + type: "flow-tester-addon", + actions: function () { + return [ + { + name: "addon:example1", + onTestStart: function () { + let promise = Promise.resolve(); + promise = promise.then(() => { + console.log("; start: addon:example1"); + }); + return promise; + }, + onTestEnd: function () { + let promise = Promise.resolve(); + promise = promise.then(() => { + console.log("; end: addon:example1"); + }); + return promise; + }, + execute: function(opt) { + // opt object contains: + // - data: optional data for action + // - node: target node + // - msg: message object + return new Promise((resolve, reject) => { + const action = opt.action; + console.log(action.value); + resolve(); + }); + } + }, + { + name: "addon:example2", + execute: function(opt) { + return new Promise((resolve, reject) => { + const msg = opt.msg; + const payload = msg.payload; + if (payload) { + try { + const action = opt.action; + const rex = new RegExp(action.value); + if (rex.test(payload)) { + resolve(); + } + } + catch (e) { + console.log("error:", e); + } + } + reject(); + }); + } + } + ]; + } + }); +}; diff --git a/examples/node-red-contrib-flow-tester-addon/src/locales/en-US/flow-tester-addon.json b/examples/node-red-contrib-flow-tester-addon/src/locales/en-US/flow-tester-addon.json new file mode 100644 index 0000000..3f29600 --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/src/locales/en-US/flow-tester-addon.json @@ -0,0 +1,4 @@ +{ + "label": { + } +} diff --git a/examples/node-red-contrib-flow-tester-addon/src/style.css b/examples/node-red-contrib-flow-tester-addon/src/style.css new file mode 100644 index 0000000..e69de29 diff --git a/examples/node-red-contrib-flow-tester-addon/test/test.js b/examples/node-red-contrib-flow-tester-addon/test/test.js new file mode 100644 index 0000000..be2c5fa --- /dev/null +++ b/examples/node-red-contrib-flow-tester-addon/test/test.js @@ -0,0 +1,2 @@ +describe("Flow tester addon tests", function() { +}); diff --git a/nodes/flow-test-config.html b/nodes/flow-test-config.html new file mode 100644 index 0000000..5f9141c --- /dev/null +++ b/nodes/flow-test-config.html @@ -0,0 +1,46 @@ + + + + + diff --git a/nodes/flow-test-config.js b/nodes/flow-test-config.js new file mode 100644 index 0000000..81c3303 --- /dev/null +++ b/nodes/flow-test-config.js @@ -0,0 +1,13 @@ +module.exports = function(RED) { + "use strict"; + + function FlowTestConfig(n) { + RED.nodes.createNode(this, n); + const node = this; + node.name = n.name; + node.tests = n.tests; + node.currentTest = n.currentTest; + } + + RED.nodes.registerType("flow-test-config", FlowTestConfig); +}; diff --git a/package.json b/package.json index a4fa2e9..8425ee9 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,17 @@ "version": ">=2.0.0", "plugins": { "flow-tester": "dist/flow-tester.js" - } + }, + "nodes": { + "flow-test-config": "nodes/flow-test-config.js" + } }, + "contributors": [ + { + "name": "Hiroyasu Nishiyama", + "email": "hiroyasu.nishiyama.uq@hitachi.com" + } + ], "devDependencies": { "fs-extra": "^10.0.0", "html-minifier": "^4.0.0", diff --git a/src/flow-tester.html b/src/flow-tester.html index 34150b7..932594f 100644 --- a/src/flow-tester.html +++ b/src/flow-tester.html @@ -1,17 +1,1929 @@ - + diff --git a/src/flow-tester.js b/src/flow-tester.js index fc1c93d..7f3d538 100644 --- a/src/flow-tester.js +++ b/src/flow-tester.js @@ -1,7 +1,867 @@ module.exports = (RED) => { + "use strict"; + + const vm = require("vm"); + const assert = require("assert"); + const promisify = require("util").promisify; + + let maxActions = 0; // max number of actions + let actionCount = 0; // currently executed actions count + + let numberOfChecks = 0; // number of checks in current test case + let successActions = []; // info. of successful actions + let failActions = []; // info. of failed actions + + let resolveWait = (() => {}); // resolve function for wait promise + let currentWait = null; // handle for wait action + const TIMEOUT = 10; // timeout period of flow execution + + // actions map: event name -> node id -> action info + const actionMap = { + setup: [], // executed on test start + cleanup: [], // executed on test end + + recv: [], // executed on message receive + stub: [], // executed on message receive to make + // stubbed execution of target node + send: [], // executed on message send + }; + + var addonList = []; + + /** + * Add event log to editor sidebar log area + * @param {string} msg - message to be added + */ + function log(msg) { + RED.comms.publish("flow-test:log", msg); + } + + /** + * Show notification on editor + * @param {string} msg - message to be shown + */ + function notify(msg) { + RED.comms.publish("flow-test:notify", msg); + } + + /** + * Notify that execution reached the maxnimum number + */ + function reportMaxActions() { + RED.comms.publish("flow-test:maxActions", {}); + } + + /** + * Notify result of match action + * @param {Object} result - result information object with: + * result: true/false + * suiteID: test suite id + * testID: test case id + */ + function sendMatchResult(result) { + RED.comms.publish("flow-test:match-result", result); + } + + /** + * Execute send action: send value to target node + * @param {string} target - target node id + * @param {any} value - payload value + * @returns {Promise} + */ + function executeSend(target, value) { + log(`send: ${JSON.stringify(value)} to ${target}`); + const node = RED.nodes.getNode(target); + node.receive({ + payload: value + }); + return Promise.resolve(true); + } + + /** + * Execute click action: click button of target node + * @param {string} target - target node id + * @returns {Promise} + */ + function executeClick(target) { + log(`click: ${target}`); + RED.comms.publish("flow-test:click", target); + return Promise.resolve(true); + } + + /** + * Execute log action: log message + * @param {string} value - logged string + * @returns {Promise} + */ + function executeLog(value) { + log(`log: ${value}`); + RED.log.info(value); + return Promise.resolve(true); + } + + /** + * Execute set action: set value to msg, flow context, or global context + * @param {Object} dst - destination location (type, value) + * @param {Object} src - source value (type, value) + * @param {string} nid - target node id + * @param {Object} msg - target message + * @returns {Promise} + */ + function executeSet(dst, src, nid, msg) { + log(`set: ${dst.type}.${dst.value} = ${JSON.stringify(src.value)}`); + const node = nid ? RED.nodes.getNode(nid) : null; + let val = src.value || ""; + switch (src.type) { + case "str": + break; + case "num": + val = Number(val); + break; + case "bool": + val = ((val === true) || (val === "true")); + break; + case "json": + val = JSON.parse(val); + break; + case "bin": + val = Buffer.from(JSON.parse(val)); + break; + case "re": + break; + case "date": + val = Date.now(); + break; + case "jsonata": + if (!src.exp) { + src.exp = RED.util.prepareJSONataExpression(val, node); + } + val = RED.util.evaluateJSONataExpression(src.exp, msg); + break; + case "env": + val = RED.util.evaluateNodeProperty(val, "env", node); + break; + } + switch (dst.type) { + case "msg": + if (msg) { + RED.util.setMessageProperty(msg, dst.value, value); + } + break; + case "global": + case "flow": + const contextKey = RED.util.parseContextStore(dst.value); + if (/\[msg/.test(contextKey.key)) { + // The key has a nest msg. reference to evaluate first + contextKey.key = RED.util.normalisePropertyExpression(contextKey.key, msg, true) + } + const target = node.context()[dst.type]; + const set = promisify(target.set); + return set(contextKey.key, val, contextKey.store); + default: + throw new Error("unexpected value type: "+src.type); + } + return Promise.resolve(true); + } + + /** + * Report result of validation + * @param {boolean} result - result of validation + * @param {Number} index - index in actions list + * @param {string} sid - test suite id + * @param {string} tid - test case id + */ + function reportResult(result, index, sid, tid) { + const info = { + result: result, + suiteID: sid, + testID: tid, + }; + var actInfo = { + index: index, + suiteID: sid, + testID: tid, + }; + if (result) { + successActions.push(actInfo); + } + else { + failActions.push(actInfo); + } + sendMatchResult(info); + if ((successActions.length +failActions.length) >= numberOfChecks) { + if (currentWait) { + // clear timeout if all validations processed + clearTimeout(currentWait); + } + resolveWait(true); + resolveWait = (()=>{}); + } + } + + /** + * Execute match action: validates message value + * @param {any} val - expected value + * @param {Object} msg - message to be checked + * @param {Number} index - index in actions list + * @param {string} sid - test suite id + * @param {string} tid - test case id + * @returns {Promise} + */ + function executeMatch(val, msg, index, sid, tid) { + log(`match: ${val}`); + let result = true; + try { + assert.deepEqual(val, msg.payload); + } + catch (e) { + result = false; + } + reportResult(result, index, sid, tid); + return Promise.resolve(true); + } + + /** + * Execute wait action: wait for specified period + * @param {Number} wait - wait period in ms + * @returns {Promise} + */ + function executeWait(wait) { + log(`wait: ${wait}`); + return new Promise((resolve) => { + resolveWait = resolve; + currentWait = setTimeout(function () { + currentWait = null; + resolve(true); + }, wait); + }); + } + + /** + * Execute function action: execute JavaScript code + * @param {string} code - JavaScript code text + * @param {boolean} performCheck - true if the code performs check + * @param {string} nid - target node id + * @param {Object} msg - message + * @param {Number} index - index in actions list + * @param {string} sid - test suid id + * @param {string} tid - test case id + * @returns {Promise} + */ + function executeFunction(code, performCheck, nid, msg, + index, sid, tid) { + log(`function: ${code}`); + const node = nid ? RED.nodes.getNode(nid) : null; + const ctxDef = { + console: console, + node: node, + msg: msg + }; + if (performCheck) { + // `check` function for validation + ctxDef.check = ((result) => { + reportResult(result, index, sid, tid); + }); + } + const ctx = vm.createContext(ctxDef); + const script = ` +(function () { + try { + ${code}; + } + catch (e) { + return Promise.reject(e); + } + return Promise.resolve(true); +})()`; + return vm.runInContext(script, ctx); + } + + /** + * Find addon action definition + * @param {string} name - name of action + * @returns {Object} action definition + */ + function findAddonAction(name) { + for (let addon of addonList) { + for (let def of addon.actions()) { + if (def.name === name) { + return def; + } + } + } + return null; + } + + /** + * Execute action + * @param {Object} action - action description object + * @param {Object} node - target node + * @param {Object} msg - message + * @returns {Promise} + */ + function executeAction(action, node, msg) { + actionCount++; + if (actionCount >= maxActions) { + reportMaxActions(); + throw new Error("action count exceeded limit"); + } + try { + switch (action.kind) { + case "send": + return executeSend(action.target, action.value); + case "click": + return executeClick(action.target); + case "log": + return executeLog(action.value); + case "set": + return executeSet(action.dst, action.src, node, msg); + case "match": + return executeMatch(action.value, msg, + action.index, action.suiteID, action.testID); + case "wait": + return executeWait(action.wait); + case "function": + return executeFunction(action.code, action.performCheck, + node, msg, + action.index, action.suiteID, action.testID); + default: + const addon = findAddonAction(action.kind); + if (addon) { + return executeAddonAction(addon, action, node, msg); + } + else { + return Promise.reject(new Error("unexpected kind of action: " +action.kind)); + } + } + } + catch (e) { + const msg = "error: "+ e; + log(msg); + console.log(msg); + } + return Promise.resolve(true); + } + + /** + * Process actions defined for event + * @param {array} actions - array of actions + * @param {Object} node - target node + * @param {Object} msg - message + * @returns {Promise} + */ + function processEvent(actions, node, msg) { + let promise = Promise.resolve(true); + if (actions) { + for (let action of actions) { + promise = promise.then(() => + executeAction(action, node, msg) + ); + } + } + return promise; + } + + /** + * Clear hooks and actions map + */ + function clearActions() { + RED.hooks.remove("onReceive.flow-test"); + RED.hooks.remove("preRoute.flow-test"); + RED.hooks.remove("onSend.flow-test"); + + actionMap.setup = []; + actionMap.cleanup = []; + + actionMap.recv = []; + actionMap.stub = []; + actionMap.send = []; + } + + /** + * Initialize flow testing plugin + * @returns {Promise} + */ + function init() { + if (currentWait) { + clearTimeout(currentWait); + } + clearActions(); + + // register hook for recv event + RED.hooks.add("onReceive.flow-test", (event, done) => { + const dst = event.destination.id; + const actions = actionMap.recv[dst]; + + if (actions) { + const msg = event.msg; + const promise = processEvent(actions, dst, msg); + promise.then(() => { + done(); + }).catch((err) => { + log(err.toString()); + done(); + }); + } + else { + done(); + } + }); + + // register hook for stub event + RED.hooks.add("preRoute.flow-test", (event, done) => { + const src = event.source.id; + const actions = actionMap.stub[src]; + + if (actions) { + const msg = event.msg; + const promise = processEvent(actions, src, msg); + promise.then(() => { + done(false); + }).catch((err) => { + log(err); + done(false); + }); + } + else { + done(); + } + }); + + // register hook for send event + RED.hooks.add("onSend.flow-test", (events, done) => { + let promise = Promise.resolve(); + for (let event of events) { + const src = event.source.id; + const msg = event.msg; + const actions = actionMap.send[src]; + + if (actions) { + promise = promise.then(() => { + return processEvent(actions, src, msg); + }); + }; + } + promise.then(() => { + done(); + }).catch((err) => { + log(err); + done(false); + }); + }); + + + return Promise.resolve(true); + } + + + /** + * Register actions to actionMap + * @param {string} event - event name + * @param {array} actions - list of actions + * @param {string} sid - test suite id + * @param {string} tid - test case id + * @returns {Promise} + */ + function registerActions(event, actions, sid, tid) { + switch (event) { + case "setup": + case "cleanup": + case "recv": + case "stub": + case "send": + Object.entries(actions).forEach(([node, acts]) => { + if (acts.length > 0) { + actionMap[event][node] = acts; + let i = 0; + acts.forEach((act) => { + act.index = i; + act.suiteID = sid; + act.testID = tid; + i++; + }); + } + }); + break; + default: + throw new Error("unexpected event: " +event); + } + return Promise.resolve(true); + } + + function executeTestStartAddon() { + let promise = Promise.resolve(); + for (let addon of addonList) { + for (let action of addon.actions()) { + const cb = action.onTestStart; + if (cb) { + promise = promise.then(() => { + return cb(); + }); + } + } + } + return promise; + } + + + function executeTestEndAddon() { + let promise = Promise.resolve(); + for (let addon of addonList) { + for (let action of addon.actions()) { + const cb = action.onTestEnd; + if (cb) { + promise = promise.then(() => { + return cb(); + }); + } + } + } + return promise; + } + + /** + * Initialize & process actions for setup event + * @param {Number} maxActs - max number of actions + * @returns {Promise} + */ + function setup(maxActs) { + maxActions = maxActs; + actionCount = 0; + successActions = []; + failActions = []; + // execute addon start callbacks + let promise = executeTestStartAddon(); + // execute global actions first + const actions = actionMap.setup["_global_"]; + promise = promise.then(() => { + return processEvent(actions, null, null); + }); + // then, execute nodes events + for (let id of Object.keys(actionMap.setup)) { + if (id !== "_global_") { + const actions = actionMap.setup[id]; + promise = promise.then(() => { + const node = RED.nodes.getNode(id); + return processEvent(actions, node, null); + }); + } + } + return promise; + } + + /** + * Process actions for cleanup event + * @param {Number} maxActs - max number of actions + * @returns {Promise} + */ + function cleanup() { + // execute global actions first + const actions = actionMap.cleanup["_global_"]; + let promise = processEvent(actions, null, null); + // then, execute nodes events + for (let id of Object.keys(actionMap.setup)) { + if (id !== "_global_") { + const actions = actionMap.cleanup[id]; + promise = promise.then(() => { + const node = RED.nodes.getNode(id); + return processEvent(actions, node, null); + }); + } + } + promise = promise.then(() => { + return executeTestEndAddon(); + }); + // finally, clear actions + return promise.then(() => { + clearActions(); + }); + } + + function registerAddon(addon) { + addonList.push(addon); + } + + function executeAddonAction(addon, action, node, msg) { + const exec = addon.execute; + const report = action.performCheck; + const index = action.index; + const sid = action.suiteID; + const tid = action.testID;; + if (exec) { + try { + return exec({ + action: action, + node: node, + msg: msg + }).then(() => { + if (report) { + reportResult(true, index, sid, tid); + } + }).catch(() => { + if (report) { + reportResult(false, index, sid, tid); + } + });; + } + catch (e) { + const msg = "error:"+ e; + log(msg); + console.log(msg); + if (report) { + reportResult(false, index, sid, tid); + } + return Promise.reject(); + } + }; + return Promise.resolve(); + } + + // Register flow testing plugin RED.plugins.registerPlugin('node-red-flow-tester', { settings: { "*": { exportable: true } + }, + onadd: () => { + const routeAuthHandler = RED.auth.needsPermission("flow-tester.write"); + + var plugins = RED.plugins.getByType('flow-tester-addon'); + plugins.forEach(function(plugin) { + registerAddon(plugin); + }) + RED.events.on("registry:plugin-added", function (id) { + var plugin = RED.plugins.get(id); + if (plugin.type === "flow-tester-addon") { + registerAddon(plugin); + } + }); + + + // Internal APIs for flow testing + RED.httpAdmin.post( + "/flow-tester/executeAction/:action", + routeAuthHandler, + (req, res) => { + try { + const kind = req.params.action; + const opt = req.body; + let promise = Promise.resolve(true); + switch (kind) { + case "init": + // initialize runtime part of flow-tester plugin + promise = init(); + break; + case "registerActions": + promise = registerActions(opt.event, opt.actions, opt.suiteID, opt.testID); + break; + case "setup": + promise = setup(opt.maxActions); + break; + case "cleanup": + // cleanup runtime part of flow-tester plugin + promise = cleanup(); + break; + case "send": + // send a message to specified node + promise = executeSend(opt.target, opt.value); + break; + case "click": + // click the button of target node + // nothing to do + break; + case "log": + // log message + promise = executeLog(opt.value); + break; + case "set": + // set context or environment variable + promise = executeSet(opt.dst, opt.src); + break; + case "wait": + // wait for specified time + promise = executeWait(opt.wait); + break; + case "function": + // execute JavaScript code + promise = executeFunction(opt.code); + break; + default: + const addon = findAddonAction(action.kind); + if (addon) { + promise = executeAddonAction(addon, action, node, msg); + } + else { + console.log("unexpected action kind: ", kind); + } + break; + } + promise.then((result) => { + res.json(result || {}); + }).catch((err) => { + const msg = "error: " +err; + console.log(msg); + notify(msg); + res.sendStatus(400); + }); + } + catch (e) { + console.log(e.stack); + res.sendStatus(400); + } + }); + + // API for getting list of test cases + RED.httpAdmin.get( + "/flow-tester/testCase", + routeAuthHandler, + (req, res) => { + const suiteList = []; + const nodes = []; + RED.nodes.eachNode((node) => { + if (node.type === "flow-test-config") { + nodes.push(node); + } + }); + nodes.forEach((node) => { + const testList = []; + const suite = { + id: node.id, + name: node.name, + tests: testList + }; + suiteList.push(suite); + const cases = node.tests; + cases.forEach((testCase) => { + const test = { + id: testCase.id, + name: testCase.name + }; + testList.push(test); + }); + }); + res.json(suiteList); + } + ); + + /** + * Find test information for specified ids + * @param {Object} suite - test suite information + * @param {string} tid - test case id + * @returns {Object} test information + */ + function findTest(suite, tid) { + if (suite) { + const tests = suite.tests; + const test = tests.find((t) => { + return (t.id === tid); + }); + if (test) { + return test; + } + } + return null; + } + + /** + * Register actions for a test case + * @param {Object} suite - test suite information + * @param {Object} test - test case information + * @returns {Promise} + */ + function registerTestActions(suite, test) { + log("Running test: " +test.name); + const sid = suite.id; + const tid = test.id; + let promise = Promise.resolve(); + const actions = test.actions; + var events = Object.keys(actions); + events.forEach(function (event) { + promise = promise.then(() => { + return registerActions(event, + actions[event], + sid, tid); + }); + }); + return promise; + } + + /** + * Count number of expected checks for test case + * @param {Object} map - action definition + * @returns {Number} number of checks + */ + function countNumberOfChecks(map) { + var count = 0; + var events = Object.keys(map); + events.forEach(function (event) { + var evMap = map[event]; + var nodes = Object.keys(evMap); + nodes.forEach(function (node) { + var acts = evMap[node]; + acts.forEach(function (act) { + if (act.performCheck) { + count++; + } + }); + }); + }); + return count; + } + + function clearMatchResults() { + RED.comms.publish("flow-test:clear-match-result", {}); + } + + // API for executing a test case + RED.httpAdmin.get( + "/flow-tester/runTestCase/:suite/:test", + routeAuthHandler, + (req, res) => { + const params = req.params; + const sid = params.suite; + const tid = params.test; + const suite = RED.nodes.getNode(sid); + const test = findTest(suite, tid); + + numberOfChecks = countNumberOfChecks(test.actions); + clearMatchResults(); + + if (test) { + init().then(() => { + return registerTestActions(suite, test); + }).then(() => { + return setup(suite.maxActions) + }).then(() => { + var timeout = (test.timeout || TIMEOUT) *1000; + return executeWait(timeout); + }).then(() => { + return cleanup(); + }).then(() => { + const result = { + result: { + all: numberOfChecks, + success: successActions.length, + fail: failActions.length + }, + info: { + success: successActions, + fail: failActions + } + }; + res.json(result); + }).catch((err) => { + const msg = "error: " +err; + console.log(msg); + res.sendStatus(400); + }); + return; + } + res.sendStatus(400); + } + ); + + }, + onremove: () => { } }); }; diff --git a/src/style.css b/src/style.css index a4c0fb7..160bedc 100644 --- a/src/style.css +++ b/src/style.css @@ -3,5 +3,21 @@ height: 100%; display: flex; flex-direction: column; - background: red; } + +.red-ui-suite-label { +} + +.red-ui-suite-label-control { + position: absolute; + top: 0px; + right: 0px; + margin-top: 5px; + margin-right: 5px; + display: none; +} + +.red-ui-suite-label:hover .red-ui-suite-label-control { + display: inline; +} +